├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── COMMANDS.md ├── LICENSE ├── README.md ├── commands ├── addpp.js ├── ar.js ├── bmi.js ├── bpm.js ├── bttv.js ├── burningtext.js ├── calcscore.js ├── compare.js ├── define.js ├── emojipedia.js ├── emote.js ├── eval.js.disabled ├── fantasychange.js ├── fantasyname.js ├── ffz.js ├── firsts.js ├── flowa.js ├── flowabot.js ├── help.js ├── ign-set.js ├── lastfm.js ├── lazerscore.js ├── level.js ├── np.js ├── oppai.js ├── oppai2.js ├── osu-track.js ├── osu-untrack.js ├── osu.js ├── packs.js ├── ping.js ├── ping2.js ├── pins.js ├── query.js ├── recent.js ├── render.js ├── rosu.js ├── scamge.js ├── score.js ├── strains.js ├── streamin.js ├── tap.js ├── time.js ├── top.js ├── tops.js ├── urban.js ├── viewers.js ├── w.js ├── weather.js └── with.js ├── emotes ├── A_Rank.png ├── B_Rank.png ├── C_Rank.png ├── D_Rank.png ├── F_Rank.png ├── SH_Rank.png ├── S_Rank.png ├── XH_Rank.png └── X_Rank.png ├── fantasynamegen.js ├── generate-commands-md.js ├── generate-config.js ├── handlers └── updatelastmessage.js ├── helper.js ├── index.js ├── jsconfig.json ├── osu.js ├── package-lock.json ├── package.json ├── renderer ├── beatmap │ ├── followpoints.js │ ├── hitsounds.js │ ├── mods │ │ ├── mods.js │ │ ├── random.js │ │ └── reflection.js │ ├── pp.js │ ├── process.js │ ├── replay.js │ ├── slider.js │ ├── stacking.js │ ├── util.js │ └── worker.js ├── render_frame.js ├── render_worker.js ├── res │ ├── argon │ │ ├── drum-hitclap.wav │ │ ├── drum-hitfinish.wav │ │ ├── drum-hitnormal.wav │ │ ├── drum-hitwhistle.wav │ │ ├── drum-sliderslide.wav │ │ ├── drum-slidertick.wav │ │ ├── drum-sliderwhistle.wav │ │ ├── drum-spinnerbonus.wav │ │ ├── drum-spinnerspin.wav │ │ ├── normal-hitclap.wav │ │ ├── normal-hitfinish.wav │ │ ├── normal-hitnormal.wav │ │ ├── normal-hitwhistle.wav │ │ ├── normal-sliderslide.wav │ │ ├── normal-slidertick.wav │ │ ├── normal-sliderwhistle.wav │ │ ├── normal-spinnerbonus.wav │ │ ├── normal-spinnerspin.wav │ │ ├── soft-hitclap.wav │ │ ├── soft-hitfinish.wav │ │ ├── soft-hitnormal.wav │ │ ├── soft-hitwhistle.wav │ │ ├── soft-sliderslide.wav │ │ ├── soft-slidertick.wav │ │ ├── soft-sliderwhistle.wav │ │ ├── soft-spinnerbonus.wav │ │ └── soft-spinnerspin.wav │ ├── hitsounds │ │ ├── LICENSE │ │ ├── combobreak.mp3 │ │ ├── drum-hitclap.wav │ │ ├── drum-hitfinish.wav │ │ ├── drum-hitnormal.wav │ │ ├── drum-hitwhistle.wav │ │ ├── drum-sliderslide.wav │ │ ├── drum-slidertick.wav │ │ ├── drum-sliderwhistle.wav │ │ ├── normal-hitclap.wav │ │ ├── normal-hitfinish.wav │ │ ├── normal-hitnormal.wav │ │ ├── normal-hitwhistle.wav │ │ ├── normal-sliderslide.wav │ │ ├── normal-slidertick.wav │ │ ├── normal-sliderwhistle.wav │ │ ├── soft-hitclap.wav │ │ ├── soft-hitfinish.wav │ │ ├── soft-hitnormal.wav │ │ ├── soft-hitwhistle.wav │ │ ├── soft-sliderslide.wav │ │ ├── soft-slidertick.wav │ │ └── soft-sliderwhistle.wav │ ├── images │ │ └── arrow.svg │ └── lagtrain.mp3 ├── ur.js ├── ur_processor.js ├── webui.js └── webui │ ├── index.html │ └── index.js ├── res └── logo.png ├── underscore-min.js └── upload-emojis.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | indent_size = 4 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: LeaPhant 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: LeaPhant # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: https://paypal.me/LeaPhant 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore node_modules symlink 2 | node_modules.nosync 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | 66 | # localstorage 67 | scratch 68 | 69 | # mac 70 | .DS_Store 71 | 72 | # config and credentials 73 | config.json 74 | 75 | build 76 | osumaps 77 | commands/disabled 78 | maps/ 79 | renderer/webui/output 80 | replays 81 | .vscode 82 | 83 | vscode-profile* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lea Seibert 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 |

2 | 3 |

flowabot

4 | 5 | **flowabot** is a modular discord bot with a focus on osu! features. Instead of me explaining this with words, I'll just leave a demonstration video: 6 | 7 |

8 | 9 |

Jump to Installation.

10 | 11 |

Main Features

12 | 13 |

Fancy scorecards with unique information like a difficulty graph or unstable rate

14 | 15 |

16 | 17 |

Get an overview of your osu! stats

18 | 19 |

20 | 21 |

Render a video or picture of any osu! beatmap

22 | 23 |

24 | 25 |

Get a graph with the hardest parts of a beatmap

26 | 27 |

28 | 29 |

Get a graph of the bpm changes throughout a beatmap

30 | 31 |

32 | 33 |

You can find more features in the commands list.

34 | 35 |

Installation

36 | 37 | ### Prerequisites 38 | 39 | - **Using Linux or macOS is recommended** (No support for Windows, here's two unofficial guides to run it on Windows if you wanna try anyway: https://github.com/LeaPhant/flowabot/issues/9, https://pastebin.com/g6yuxCCf) 40 | - Git (https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 41 | - Node.js 14 or higher (https://nodejs.org/) 42 | - node-gyp (https://github.com/nodejs/node-gyp#installation) 43 | - Be sure to have gcc/g++ installed, e.g. `sudo apt install build-essential` on Ubuntu 44 | - Discord bot token and client ID (https://discord.com/developers/applications/) 45 | - osu!api key (https://osu.ppy.sh/p/api/) 46 | - node-canvas dependencies (https://github.com/Automattic/node-canvas#compiling) 47 | 48 | ### Setup 49 | 50 | **Clone the repo and enter the bot directory** 51 | 52 | git clone https://github.com/LeaPhant/flowabot.git 53 | cd flowabot 54 | 55 | --- 56 | **Install all modules** 57 | 58 | npm i 59 | 60 | 61 | --- 62 | 63 | **Now you'll be able to use the configuration wizard.** 64 | 65 | npm run config 66 | 67 | *Follow the on-screen instructions, just press enter without typing anything for features you don't need.* 68 | 69 | --- 70 | 71 | **You should be able to run the bot now.** 72 | 73 | npm start 74 | 75 | *If you provided a Discord client ID during the configuration you will receive an invite link to add the bot to your server.* 76 | 77 | --- 78 | 79 | **Make the grade emojis work (S rank, A rank, etc.)** 80 | 81 | npm run emojis 82 | 83 | *This script will automatically upload the grade emojis to a server you'll have to pick. If there are no free emoji slots create a new server just for the bot to use its emojis from.* 84 | 85 | --- 86 | 87 | **To keep the bot running in the background [install pm2](http://pm2.keymetrics.io/docs/usage/quick-start/) and run** 88 | 89 | pm2 start npm --name flowabot -- start 90 | 91 | **To start the bot on system boot use** 92 | 93 | pm2 save 94 | pm2 startup 95 | 96 | *(This is only tested on Linux)* 97 | 98 |

Patrons

99 | 100 | Thanks to anyone supporting me on [Patreon](https://www.patreon.com/LeaPhant), especialy the following peeps who decided to leave $5 or more per month 😳 101 | 102 | **WitchOfFrost** 103 | -------------------------------------------------------------------------------- /commands/addpp.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: 'addpp', 6 | description: "Calculate new total pp after achieving a certain top play.", 7 | argsRequired: 1, 8 | usage: ' [username] [beatmap_id]', 9 | example: [ 10 | { 11 | run: "addpp 300", 12 | result: "Returns your total pp with an additional 300pp score." 13 | }, 14 | { 15 | run: "addpp 300+350", 16 | result: "Returns your total pp with an additional 300 and 350pp score." 17 | }, 18 | { 19 | run: 'addpp 1100 Vaxei 1860433', 20 | result: "Returns Vaxei's total pp if their score on /b/1860433 awarded 1100pp." 21 | } 22 | ], 23 | configRequired: ['credentials.client_id', 'credentials.client_secret'], 24 | call: obj => { 25 | return new Promise((resolve, reject) => { 26 | let { argv, msg, user_ign } = obj; 27 | 28 | let pp_to_add = []; 29 | let acc_to_use = {}; 30 | let pp_to_add_pars = argv[1].split("+"); 31 | 32 | pp_to_add_pars.forEach(function(value, index){ 33 | let acc_to_add_pars = value.split(":"); 34 | if(acc_to_add_pars.length > 1){ 35 | let acc_to_add = parseFloat(acc_to_add_pars[1]); 36 | acc_to_use[index] = acc_to_add; 37 | } 38 | pp_to_add.push(parseFloat(value)); 39 | }); 40 | 41 | let add_to_user; 42 | let beatmap = ""; 43 | 44 | if(argv[2]){ 45 | if(argv[2].startsWith("<@")) 46 | argv[2] = argv[2].substr(2).split(">")[0]; 47 | 48 | if(argv[2].toLowerCase() in user_ign) 49 | add_to_user = user_ign[argv[2].toLowerCase()]; 50 | else 51 | add_to_user = argv[2]; 52 | }else{ 53 | if(user_ign[msg.author.id] === undefined){ 54 | reject(helper.commandHelp('ign-set')); 55 | }else{ 56 | add_to_user = user_ign[msg.author.id]; 57 | } 58 | } 59 | 60 | if(argv[3]){ 61 | for(let x = 3; x < argv.length; x++){ 62 | beatmap += argv[x] + " "; 63 | } 64 | beatmap = beatmap.trim(); 65 | } 66 | 67 | let mode = 0; 68 | 69 | if(add_to_user && pp_to_add.length > 0){ 70 | osu.add_pp(add_to_user, pp_to_add, beatmap, output => { 71 | resolve(output); 72 | }); 73 | }else{ 74 | reject(helper.commandHelp('addpp')); 75 | } 76 | }); 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /commands/ar.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: 'ar', 6 | description: "Calculate Approach Rate values and miliseconds with mods applied.", 7 | argsRequired: 1, 8 | usage: ' [+mods]', 9 | example: { 10 | run: "ar 8 +DT", 11 | result: "Returns AR of AR8 with DT applied." 12 | }, 13 | call: obj => { 14 | return new Promise((resolve, reject) => { 15 | let { argv } = obj; 16 | 17 | let ar = parseFloat(argv[1]); 18 | let mods = argv.length > 2 ? argv[2].toUpperCase() : ""; 19 | resolve(osu.calculate_ar(ar, mods)); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /commands/bmi.js: -------------------------------------------------------------------------------- 1 | const helper = require('../helper.js'); 2 | 3 | module.exports = { 4 | command: 'bmi', 5 | description: "Calculate your BMI.", 6 | usage: ' ', 7 | argsRequired: 2, 8 | example: [ 9 | { 10 | run: "bmi 185cm 70kg", 11 | example: "Returns BMI for 185cm height and 70kg weight." 12 | }, 13 | { 14 | run: "bmi 1.56m 56kg", 15 | example: "Returns BMI for 1.56m height and 56kg weight." 16 | } 17 | ], 18 | call: obj => { 19 | let { argv } = obj; 20 | let weight, height; 21 | 22 | argv.forEach(arg => { 23 | if(arg.endsWith("cm")) 24 | height = parseFloat(arg); 25 | else if(arg.endsWith("m")) 26 | height = parseFloat(arg) * 100; 27 | else if(arg.endsWith("kg")) 28 | weight = parseFloat(arg); 29 | }); 30 | 31 | if(!weight || !height) 32 | return helper.commandHelp('bmi'); 33 | 34 | let bmi = (weight / Math.pow(height, 2) * 10000).toFixed(1); 35 | let description; 36 | 37 | if(bmi < 19.5) 38 | description = "Underweight (<19.5)"; 39 | else if(bmi < 24.5) 40 | description = "Healthy Weight (19.5 - 24.4)"; 41 | else if(bmi < 30) 42 | description = "Overweight (24.5 - 29.9)"; 43 | else if(bmi < 40) 44 | description = "Obesity (30 - 39.9)"; 45 | else 46 | description = "Heavy Obesity (>40)"; 47 | 48 | return `Your BMI is ${bmi} (${description})`; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /commands/bpm.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const os = require('os'); 3 | const { execFileSync } = require('child_process'); 4 | const URL = require('url'); 5 | 6 | const osu = require('../osu.js'); 7 | const helper = require('../helper.js'); 8 | const config = require('../config.json'); 9 | 10 | module.exports = { 11 | command: 'bpm', 12 | description: "Show a visual BPM graph over time for a beatmap.", 13 | usage: '[beatmap url] [+mods]', 14 | example: [ 15 | { 16 | run: "bpm", 17 | result: "Returns BPM graph for the last beatmap." 18 | }, 19 | { 20 | run: "bpm https://osu.ppy.sh/b/75 +DT", 21 | result: "Returns BPM graph with DT for specific beatmap." 22 | } 23 | ], 24 | configRequired: ['debug'], 25 | call: obj => { 26 | return new Promise((resolve, reject) => { 27 | let { argv, msg, last_beatmap } = obj; 28 | 29 | let beatmap_id, beatmap_promise, download_promise, beatmap_url, mods = "", custom_url = false; 30 | 31 | argv.slice(1).forEach(arg => { 32 | if(arg.startsWith('+')) 33 | mods = arg.toUpperCase().substr(1); 34 | else{ 35 | beatmap_url = arg; 36 | beatmap_promise = osu.parse_beatmap_url(beatmap_url); 37 | beatmap_promise.then(response => { 38 | beatmap_id = response; 39 | if(!beatmap_id) custom_url = true; 40 | }); 41 | } 42 | }); 43 | 44 | Promise.resolve(beatmap_promise).then(() => { 45 | if(!(msg.channel.id in last_beatmap)){ 46 | msg.channel.send(helper.commandHelp('bpm')); 47 | return; 48 | }else if(!beatmap_id && !custom_url){ 49 | beatmap_id = last_beatmap[msg.channel.id].beatmap_id; 50 | download_promise = helper.downloadBeatmap(beatmap_id).catch(helper.error); 51 | 52 | mods = Array.isArray(last_beatmap[msg.channel.id].mods) 53 | ? last_beatmap[msg.channel.id].mods.map(mod => mod.acronym).join('') : ''; 54 | } 55 | 56 | let download_path = path.resolve(config.osu_cache_path, `${beatmap_id}.osu`); 57 | 58 | if(!beatmap_id){ 59 | let download_url = URL.parse(beatmap_url); 60 | download_path = path.resolve(os.tmpdir(), `${Math.floor(Math.random() * 1000000) + 1}.osu`); 61 | 62 | download_promise = helper.downloadFile(download_path, download_url); 63 | 64 | download_promise.catch(reject); 65 | } 66 | 67 | Promise.resolve(download_promise).then(() => { 68 | osu.get_bpm_graph(download_path, mods).then(res => { 69 | if(beatmap_id){ 70 | helper.updateLastBeatmap({ 71 | beatmap_id, 72 | mods, 73 | fail_percent: last_beatmap[msg.channel.id].fail_percent || 1, 74 | acc: last_beatmap[msg.channel.id].acc || 100 75 | }, msg.channel.id, last_beatmap); 76 | } 77 | 78 | resolve({files: [{ attachment: Buffer.from(res, 'base64'), name: 'bpm.png' }]}); 79 | }).catch(err => { 80 | reject(err); 81 | return false; 82 | }) 83 | }); 84 | }); 85 | }); 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /commands/bttv.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const os = require('os'); 5 | 6 | const helper = require('../helper.js'); 7 | const Discord = require('discord.js'); 8 | 9 | const bttvApi = axios.create({ 10 | baseURL: 'https://api.betterttv.net/3/emotes/shared' 11 | }); 12 | 13 | module.exports = { 14 | command: 'bttv', 15 | description: "Show a BTTV emote by name. Emotes from .", 16 | argsRequired: 1, 17 | usage: '', 18 | example: { 19 | run: 'bttv WoweeHOP', 20 | result: 'Returns WoweeHOP BTTV emote' 21 | }, 22 | call: obj => { 23 | return new Promise((resolve, reject) => { 24 | let { argv, msg } = obj; 25 | 26 | let q = argv[1].toLowerCase(); 27 | 28 | bttvApi.get('/search', { 29 | params: { 30 | query: q, 31 | limit: 100 32 | } 33 | }).then(response => { 34 | let emoticons = response.data; 35 | 36 | if(emoticons.length > 0){ 37 | let exactMatch = emoticons.filter(a => a.code.toLowerCase() == q); 38 | let emote; 39 | if(exactMatch.length > 0){ 40 | emote = exactMatch[0] 41 | }else{ 42 | emote = emoticons[0] 43 | } 44 | 45 | let emoteUrl = `https://cdn.betterttv.net/emote/${emote.id}/3x`; 46 | 47 | let file = path.resolve(os.tmpdir(), `emote_${emote.code}_${helper.getRandomArbitrary(1000, 9999)}.${emote.imageType}`); 48 | 49 | axios.get(emoteUrl, {responseType: 'stream'}).then(response => { 50 | let stream = response.data.pipe(fs.createWriteStream(file)); 51 | 52 | stream.on('finish', () => { 53 | resolve({embed: { 54 | title: emote.code, 55 | url: `https://betterttv.com/emotes/${emote.id}`, 56 | image: { 57 | url: `attachment://emote.${emote.imageType}` 58 | }, 59 | footer: { 60 | text: `Submitted by ${emote.user.displayName}` 61 | } 62 | }, files: [{ attachment: file, name: `emote.${emote.imageType}` }], remove_path: file }); 63 | }); 64 | }); 65 | }else{ 66 | reject(`BTTV emote ${q} not found.`); 67 | } 68 | }).catch(err => { 69 | helper.error(err); 70 | reject(`BTTV emote ${q} not found.`); 71 | }); 72 | }); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /commands/burningtext.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const https = require('https'); 3 | const helper = require('../helper.js'); 4 | const FormData = require('form-data') 5 | 6 | 7 | module.exports = { 8 | command: ['burningtext', 'flametext', 'cooltext'], 9 | description: "Generate a burning text gif.", 10 | argsRequired: 1, 11 | usage: '', 12 | example: { 13 | run: 'burningtext Burning Text', 14 | result: "It burns." 15 | }, 16 | call: obj => { 17 | return new Promise((resolve, reject) => { 18 | let { argv } = obj; 19 | 20 | let args = argv 21 | args.shift() 22 | 23 | const text = args.join(" ") 24 | 25 | const formData = new FormData() 26 | 27 | formData.append("LogoID", 4) 28 | formData.append("Text", text) 29 | formData.append("FontSize", 70) 30 | formData.append("Color1_color", "#FF0000") 31 | formData.append("Integer1", 15) 32 | formData.append("Boolean1", "on") 33 | formData.append("Integer9", 0) 34 | formData.append("Integer13", "on") 35 | formData.append("Integer12", "on") 36 | formData.append("BackgroundColor_color", "#FFFFFF") 37 | 38 | axios.post("https://cooltext.com/PostChange", formData, { 39 | headers: formData.getHeaders() 40 | }).then(response => { 41 | const agent = new https.Agent({ 42 | rejectUnauthorized: false 43 | }); 44 | 45 | axios.get(response.data.renderLocation, {httpsAgent: agent, method: "GET", responseType: "stream"}).then(response => { 46 | let attachment = [{ 47 | attachment: response.data, 48 | name: text.substring(0,1024).replace(/(\r\n|\n|\r)/g,"") + '.gif' 49 | }] 50 | 51 | resolve({files: attachment}); 52 | }).catch(err => { 53 | helper.error(err); 54 | reject("Couldn't generate gif") 55 | }); 56 | }).catch(err => { 57 | helper.error(err); 58 | reject("Couldn't generate gif") 59 | }); 60 | }); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /commands/calcscore.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | const config = require('../config.json'); 4 | const bparser = require("bparser-js"); 5 | const path = require('path'); 6 | const os = require('os'); 7 | const URL = require('url'); 8 | 9 | const mods_enum = { 10 | '' : 0, 11 | 'NF' : 1, 12 | 'EZ' : 2, 13 | 'TD' : 4, 14 | 'HD' : 8, 15 | 'HR' : 16, 16 | 'SD' : 32, 17 | 'DT' : 64, 18 | 'RX' : 128, 19 | 'HT' : 256, 20 | 'NC' : 512, 21 | 'FL' : 1024, 22 | 'AT' : 2048, 23 | 'SO' : 4096, 24 | 'AP' : 8192, 25 | 'PF' : 16384, 26 | '4K' : 32768, 27 | '5K' : 65536, 28 | '6K' : 131072, 29 | '7K' : 262144, 30 | '8K' : 524288, 31 | 'FI' : 1048576, 32 | 'RD' : 2097152, 33 | 'LM' : 4194304, 34 | '9K' : 16777216, 35 | '10K' : 33554432, 36 | '1K' : 67108864, 37 | '3K' : 134217728, 38 | '2K' : 268435456, 39 | 'V2' : 536870912, 40 | }; 41 | 42 | function getModsEnum(mods){ 43 | let return_value = 0; 44 | mods.forEach(mod => { 45 | return_value |= mods_enum[mod.toUpperCase()]; 46 | }); 47 | return return_value; 48 | } 49 | 50 | module.exports = { 51 | command: ['calcscore', 'scorecalc', 'cs'], 52 | description: "Calculate maximum score for a beatmap.", 53 | argsRequired: 1, 54 | usage: ' [+mods]', 55 | example: [ 56 | { 57 | run: "calcscore https://osu.ppy.sh/b/75", 58 | result: "Returns the maximum score for Disco Prince with no mods." 59 | }, 60 | { 61 | run: "calcscore https://osu.ppy.sh/b/75 +HDHRDT", 62 | result: "Returns the maximum score for Disco Prince +HDHRDT." 63 | } 64 | ], 65 | call: obj => { 66 | return new Promise((resolve, reject) => { 67 | let { argv, msg, last_beatmap } = obj; 68 | 69 | let beatmap_url = argv[1]; 70 | let mods = argv[2]; 71 | if(mods){ 72 | if(mods.startsWith("+")){ 73 | mods = mods.substring(1); 74 | } 75 | } 76 | let download_path, download_promise; 77 | 78 | osu.parse_beatmap_url(beatmap_url, true).then(response => { 79 | let beatmap_id = response; 80 | 81 | if(!beatmap_id){ 82 | let download_url = URL.parse(beatmap_url); 83 | download_path = path.resolve(os.tmpdir(), `${Math.floor(Math.random() * 1000000) + 1}.osu`); 84 | 85 | download_promise = helper.downloadFile(download_path, download_url); 86 | download_promise.catch(reject); 87 | } 88 | 89 | Promise.resolve(download_promise).then(async () => { 90 | if(beatmap_id === undefined && download_path === undefined){ 91 | reject('Invalid beatmap url'); 92 | return false; 93 | } 94 | 95 | let beatmap_path = download_path ? download_path : path.resolve(config.osu_cache_path, `${beatmap_id}.osu`); 96 | 97 | var beatmap = new bparser.BeatmapParser(beatmap_path); 98 | let mods_enum, output, score; 99 | let sv2 = ""; 100 | 101 | if(mods){ 102 | mods_enum = getModsEnum(mods.match(/.{1,2}/g)); 103 | score = beatmap.getMaxScore(mods_enum); 104 | if(score >= 2147483647){ 105 | score = 1000000; 106 | sv2 = " ScoreV2 forced"; 107 | } 108 | output = "Max score (" + mods + "): " + new Intl.NumberFormat('en-EN').format(score) + sv2; 109 | } else { 110 | mods = "NM"; 111 | mods_enum = 0; 112 | score = beatmap.getMaxScore(mods_enum); 113 | if(score >= 2147483647){ 114 | score = 1000000 115 | sv2 = " ScoreV2 forced"; 116 | } 117 | output = "Max score (" + mods + "): " + new Intl.NumberFormat('en-EN').format(score) + sv2; 118 | } 119 | 120 | resolve(output); 121 | 122 | }); 123 | }); 124 | }); 125 | } 126 | }; 127 | -------------------------------------------------------------------------------- /commands/compare.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | async function getBeatmapIdFromMessage(msg) { 5 | if (msg.reference) { 6 | const replied_msg = await msg.channel.messages.fetch(msg.reference.messageID) 7 | const beatmap_id = osu.parse_beatmap_url(replied_msg.embeds[0].url) 8 | return beatmap_id 9 | } else { 10 | return 11 | } 12 | } 13 | 14 | module.exports = { 15 | command: ['compare', 'c'], 16 | description: "Search for best score on the last beatmap.", 17 | usage: '[username or * for all users] [+mods]', 18 | example: [ 19 | { 20 | run: "compare", 21 | result: "Returns your own best score on the last beatmap." 22 | }, 23 | { 24 | run: "compare Vaxei +mods", 25 | result: "Returns Vaxei's best score with the same mods on the last beatmap." 26 | }, 27 | { 28 | run: "compare * +HD", 29 | result: "Returns the #1 HD score on the last beatmap." 30 | } 31 | ], 32 | configRequired: ["credentials.client_id", "credentials.client_secret"], 33 | call: obj => { 34 | return new Promise((resolve, reject) => { 35 | let { argv, msg, user_ign, last_beatmap } = obj; 36 | 37 | let compare_user = helper.getUsername(argv, msg, user_ign); 38 | 39 | getBeatmapIdFromMessage(msg).then((beatmap_id) => { 40 | let compare_beatmap = beatmap_id; 41 | 42 | if(!(msg.channel.id in last_beatmap)){ 43 | reject('No recent score to compare to found. 💀'); 44 | return false; 45 | } 46 | if(!compare_beatmap){ 47 | compare_beatmap = last_beatmap[msg.channel.id].beatmap_id; 48 | } 49 | 50 | let compare_mods; 51 | 52 | argv.slice(1).forEach(arg => { 53 | if(arg.startsWith('+')){ 54 | if(arg.startsWith('+mods')) 55 | compare_mods = ['mods', ...last_beatmap[msg.channel.id].mods]; 56 | else 57 | compare_mods = arg.toUpperCase().substr(1).match(/.{1,2}/g); 58 | } 59 | if(arg == '*') 60 | compare_user = '*'; 61 | }); 62 | 63 | if(!compare_user){ 64 | if(user_ign[msg.author.id] == undefined) 65 | reject(helper.commandHelp('ign-set')); 66 | else 67 | reject(helper.commandHelp('compare')); 68 | return false; 69 | }else{ 70 | let options = { 71 | beatmap_id: compare_beatmap, 72 | mods: compare_mods 73 | }; 74 | 75 | if(compare_user != '*') 76 | options.user = compare_user; 77 | else if(compare_mods) 78 | compare_mods.splice(1, 0); 79 | 80 | options.index = 1; 81 | 82 | osu.get_score(options, (err, recent, strains_bar, ur_promise) => { 83 | if(err){ 84 | helper.error(err); 85 | reject(err); 86 | }else{ 87 | let embed = osu.format_embed(recent); 88 | helper.updateLastBeatmap(recent, msg.channel.id, last_beatmap); 89 | 90 | if(ur_promise){ 91 | resolve({ 92 | embed: embed, 93 | files: [{attachment: strains_bar, name: 'strains_bar.png'}], 94 | edit_promise: new Promise((resolve, reject) => { 95 | ur_promise.then(recent => { 96 | embed = osu.format_embed(recent); 97 | resolve({embed}); 98 | }); 99 | })}); 100 | }else{ 101 | resolve({ 102 | embed: embed, 103 | files: [{attachment: strains_bar, name: 'strains_bar.png'}] 104 | }); 105 | } 106 | } 107 | }); 108 | } 109 | }) 110 | 111 | }); 112 | } 113 | }; 114 | -------------------------------------------------------------------------------- /commands/define.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default; 2 | 3 | 4 | module.exports = { 5 | command: ['define', 'dictionary', 'dict'], 6 | description: "Shows the definition of a word.", 7 | argsRequired: 1, 8 | usage: '', 9 | example: { 10 | run: "define help", 11 | result: "Returns the definition for the word 'help'." 12 | }, 13 | call: obj => { 14 | return new Promise((resolve, reject) => { 15 | let { argv } = obj; 16 | 17 | let word = argv.slice(1).join(" "); 18 | 19 | axios.get('https://api.dictionaryapi.dev/api/v2/entries/en/' + word) 20 | .then(function (result) { 21 | let fields = []; 22 | 23 | 24 | for (let val of result.data) { 25 | for (let element of val.meanings) { 26 | fields.push({name: element.partOfSpeech, value: element.definitions[0].definition }); 27 | } 28 | } 29 | resolve({ 30 | embed: { 31 | description: result.data[0].phonetic, 32 | color: 12277111, 33 | author: { 34 | name: result.data[0].word 35 | }, 36 | fields: fields 37 | } 38 | }); 39 | }) 40 | .catch(function (error) { 41 | reject(error.response.data.message); 42 | }) 43 | }); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /commands/emojipedia.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const helper = require('../helper.js'); 3 | const cheerio = require('cheerio'); 4 | 5 | const emojipedia = axios.create({ 6 | baseURL: 'https://emojipedia.org', 7 | responseType: 'document' 8 | }) 9 | 10 | module.exports = { 11 | command: 'emojipedia', 12 | description: "Look up what an emoji looks like on all platforms (warning: spammy).", 13 | argsRequired: 1, 14 | usage: '', 15 | example: { 16 | run: "emojipedia 🤔", 17 | result: "Returns thinking emoji on all platforms." 18 | }, 19 | call: obj => { 20 | return new Promise((resolve, reject) => { 21 | let { argv, msg } = obj; 22 | 23 | let emoji = encodeURIComponent(argv.slice(1).join('').trim()); 24 | 25 | emojipedia.get(`/${emoji}/`).then(response => { 26 | let $ = cheerio.load(response.data); 27 | let embeds = [], promises = []; 28 | 29 | $('.vendor-rollout-target').each(function(){ 30 | let vendor = $(this).find('.vendor-info a'); 31 | let vendor_name = vendor.text(); 32 | let vendor_url = "https://emojipedia.org" + vendor.attr('href'); 33 | let img = $(this).find('img').attr('srcset').replace('/240/', '/60/').split(" ")[0]; 34 | embeds.push({ embed: 35 | { 36 | title: vendor_name, 37 | url: vendor_url, 38 | thumbnail: { 39 | url: img 40 | } 41 | } 42 | }); 43 | }); 44 | 45 | embeds.forEach(function(embed){ 46 | promises.push(msg.channel.send(embed)); 47 | }); 48 | 49 | promises.reduce((p, fn) => p.then(fn), Promise.resolve()); 50 | 51 | resolve(); 52 | }).catch(err => { 53 | helper.error(err); 54 | reject(`Couldn't find emoji`); 55 | }); 56 | }); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /commands/emote.js: -------------------------------------------------------------------------------- 1 | const emoji = require('node-emoji'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: ['emote', 'e'], 6 | description: "Print one or multiple emotes the bot can use in chat.", 7 | usage: ' [emote 2] [emote n]', 8 | example: { 9 | run: 'e SourPls', 10 | result: 'Returns SourPls emote.' 11 | }, 12 | argsRequired: 1, 13 | call: obj => { 14 | let { msg, argv, client } = obj; 15 | 16 | let emotes = argv.slice(1); 17 | let output = ""; 18 | 19 | emotes.forEach(emoteName => { 20 | let emote; 21 | 22 | if(emoteName.startsWith("<:") && emoteName.split(":").length > 1) 23 | emoteName = emoteName.split(":")[1]; 24 | 25 | if(emoji.hasEmoji(emoteName)) 26 | emote = emoji.find(emoteName).emoji; 27 | else if(msg.channel.type == 'text') 28 | emote = helper.emote(emoteName, msg.guild, client); 29 | else 30 | emote = helper.emote(emoteName, null, client); 31 | 32 | if(emote) 33 | output += emote.toString(); 34 | else 35 | output += " " + emoteName; 36 | }); 37 | 38 | if(output.length == 0) 39 | output = "No emote found"; 40 | 41 | return output; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /commands/eval.js.disabled: -------------------------------------------------------------------------------- 1 | const {VM} = require('vm2'); 2 | const fs = require('fs').promises; 3 | 4 | const helper = require('../helper.js'); 5 | 6 | let VMs = {}; 7 | 8 | async function initVM(user){ 9 | if(!(user in VMs)){ 10 | VMs[user] = new VM({ 11 | timeout: 100 12 | }); 13 | 14 | VMs[user].run(`const rand = function(max){ 15 | return Math.floor(Math.random() * Math.floor(max + 1)); 16 | }`); 17 | 18 | VMs[user].run(`const bonusPP = function(n){ 19 | return 416.6667 * (1 - Math.pow(0.9994, n)); 20 | }`); 21 | 22 | VMs[user].run(await fs.readFile('underscore-min.js', 'utf8')); 23 | } 24 | return VMs[user]; 25 | } 26 | 27 | module.exports = { 28 | command: ['eval'], 29 | description: "Runs JavaScript code and returns the result of the last evaluation. Underscore.js for array/object helpers and `bonusPP(n)` for bonus pp calculation are available.", 30 | usage: '[javascript code]', 31 | example: [ 32 | { 33 | run: "eval 5+5", 34 | result: "Evaluates 5+5 and returns the result." 35 | }, 36 | { 37 | run: "eval _max.([1, 2, 3])", 38 | result: "Uses Underscore.js to return the maximum value of an array." 39 | } 40 | ], 41 | call: obj => { 42 | return new Promise(async (resolve, reject) => { 43 | let { argv, msg } = obj; 44 | 45 | let eval_code = msg.content.split(" ").slice(1).join(" "); 46 | let user_id = msg.author.id; 47 | 48 | if(msg.content.includes('```')){ 49 | let eval_split = msg.content.split('```'); 50 | if(eval_split.length > 2){ 51 | eval_code = helper.replaceAll(eval_split[1], "\n", ""); 52 | } 53 | } 54 | 55 | try{ 56 | let vm = await initVM(user_id); 57 | 58 | let _msg = { 59 | content: msg.content, 60 | author: { 61 | id: msg.author.id, 62 | username: msg.author.username, 63 | discriminator: msg.author.discriminator, 64 | presence: msg.author.presence 65 | } 66 | }; 67 | 68 | eval_code = `var msg = ${JSON.stringify(_msg)};` + eval_code; 69 | let output_msg = vm.run(eval_code); 70 | output_msg = JSON.stringify(output_msg); 71 | 72 | if(output_msg) 73 | output_msg = helper.replaceAll(output_msg, "```", "`\u200B``"); 74 | else 75 | output_msg = "No output"; 76 | 77 | resolve('```' + output_msg + '```'); 78 | 79 | }catch(err){ 80 | reject(err.toString().split("\n")[0]); 81 | helper.error(err); 82 | } 83 | }); 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /commands/fantasychange.js: -------------------------------------------------------------------------------- 1 | const namegen = require('../fantasynamegen.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: 'fantasychange', 6 | argsRequired: 1, 7 | description: [ 8 | "Generates a fantasy name and changes your nickname to it.", 9 | `Available types: \`${namegen.fantasyTypes.join("\`, \`")}\``, 10 | `Available lengths: \`${namegen.fantasyLengths.join("\`, \`")}\``, 11 | "Data from ." 12 | ], 13 | usage: ' [length]', 14 | example: { 15 | run: "fantasychange elf medium", 16 | result: "Generates a medium-length elf name and sets it as your nickname." 17 | }, 18 | call: obj => { 19 | return new Promise((resolve, reject) => { 20 | let { argv, msg } = obj; 21 | 22 | let type = argv[1].toLowerCase(); 23 | let length = "medium"; 24 | 25 | if(argv.length > 2) 26 | length = argv[2]; 27 | 28 | namegen.getFantasyName(type, length, msg.author.username).then(name => { 29 | msg.member.setNickname(name) 30 | .then( () => { 31 | resolve(`You are now ${name}!`); 32 | }) 33 | .catch(err => { 34 | reject(`Couldn't change your nickname to ${name}`); 35 | helper.error(err); 36 | }); 37 | }).catch(err => { 38 | reject(err); 39 | }); 40 | }); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /commands/fantasyname.js: -------------------------------------------------------------------------------- 1 | const namegen = require('../fantasynamegen.js'); 2 | 3 | module.exports = { 4 | command: 'fantasyname', 5 | argsRequired: 1, 6 | description: [ 7 | "Generates a fantasy name.", 8 | `Available types: \`${namegen.fantasyTypes.join("\`, \`")}\``, 9 | `Available lengths: \`${namegen.fantasyLengths.join("\`, \`")}\``, 10 | "Data from ." 11 | ], 12 | usage: ' [length]', 13 | example: { 14 | run: "fantasyname elf medium", 15 | result: "Returns a medium-length elf name." 16 | }, 17 | call: obj => { 18 | return new Promise((resolve, reject) => { 19 | let { argv, msg } = obj; 20 | 21 | let type = argv[1].toLowerCase(); 22 | let length = "medium"; 23 | 24 | if(argv.length > 2) 25 | length = argv[2]; 26 | 27 | namegen.getFantasyName(type, length, msg.author.username).then(name => { 28 | msg.channel.send(name); 29 | }).catch(err => { 30 | reject(err); 31 | }); 32 | }); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /commands/ffz.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const os = require('os'); 5 | 6 | const helper = require('../helper.js'); 7 | const Discord = require('discord.js'); 8 | 9 | const ffzApi = axios.create({ 10 | baseURL: 'https://api.frankerfacez.com/v1' 11 | }); 12 | 13 | module.exports = { 14 | command: 'ffz', 15 | description: "Show an FFZ emote by name. Emotes from .", 16 | argsRequired: 1, 17 | usage: '', 18 | example: { 19 | run: 'ffz WoweeW', 20 | result: 'Returns WoweeW FFZ emote' 21 | }, 22 | call: obj => { 23 | return new Promise((resolve, reject) => { 24 | let { argv, msg } = obj; 25 | 26 | let q = argv[1]; 27 | 28 | ffzApi.get('/emoticons', { 29 | params: { 30 | q: q, 31 | sort: 'count', 32 | per_page: 200 33 | } 34 | }).then(response => { 35 | let emoticons = response.data.emoticons; 36 | 37 | if(emoticons.length > 0){ 38 | let exactMatch = emoticons.filter(a => a.name.toLowerCase() == q); 39 | let emote; 40 | if(exactMatch.length > 0){ 41 | emote = exactMatch[0] 42 | }else{ 43 | emote = emoticons[0] 44 | } 45 | 46 | let emoteUrl = ""; 47 | 48 | if("2" in emote.urls) 49 | emoteUrl = emote.urls["2"]; 50 | else 51 | emoteUrl = emote.urls["1"]; 52 | 53 | if(emoteUrl.startsWith("//")) 54 | emoteUrl = "https:" + emoteUrl; 55 | 56 | let file = path.resolve(os.tmpdir(), `emote_${emote.name}_${helper.getRandomArbitrary(1000, 9999)}.png`); 57 | 58 | axios.get(emoteUrl, {responseType: 'stream'}).then(response => { 59 | let stream = response.data.pipe(fs.createWriteStream(file)); 60 | 61 | stream.on('finish', () => { 62 | resolve({embed: { 63 | title: emote.name, 64 | url: `https://www.frankerfacez.com/emoticon/${emote.id}`, 65 | image: { 66 | url: "attachment://emote.png" 67 | }, 68 | footer: { 69 | text: `Submitted by ${emote.owner.display_name}` 70 | } 71 | }, files: [{ attachment: file, name: 'emote.png' }], remove_path: file}); 72 | }); 73 | }); 74 | }else{ 75 | reject(`FFZ emote ${q} not found.`); 76 | } 77 | }).catch(err => { 78 | helper.error(err); 79 | reject(`FFZ emote ${q} not found.`); 80 | }); 81 | }); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /commands/firsts.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | const { DateTime } = require('luxon'); 4 | const config = require('../config.json'); 5 | const fetch = require('node-fetch'); 6 | 7 | module.exports = { 8 | command: ['firsts'], 9 | description: "Show a list of first places", 10 | startsWith: true, 11 | usage: '[username]', 12 | example: [ 13 | { 14 | run: "firsts", 15 | result: "Returns your top 5 first places." 16 | }, 17 | { 18 | run: "firsts7 vaxei", 19 | result: "Returns Vaxei's top 7 first places." 20 | } 21 | ], 22 | configRequired: ['credentials.client_id', 'credentials.client_secret'], 23 | call: obj => { 24 | return new Promise((resolve, reject) => { 25 | let { argv, msg, user_ign, last_beatmap } = obj; 26 | 27 | let firsts_user = helper.getUsername(argv, msg, user_ign); 28 | 29 | let count = 5; 30 | let match = argv[0].match(/\d+/); 31 | 32 | if(match != null && !isNaN(match[0])) 33 | count = Math.max(1, Math.min(match[0], 25)); 34 | 35 | if(!firsts_user){ 36 | if(user_ign[msg.author.id] == undefined){ 37 | reject(helper.commandHelp('ign-set')); 38 | }else{ 39 | reject(helper.commandHelp('firsts')); 40 | } 41 | 42 | return false; 43 | }else{ 44 | 45 | osu.get_firsts({user: firsts_user, count},(err, response) => { 46 | if(err){ 47 | helper.error(err); 48 | reject(err); 49 | }else{ 50 | const { firsts, user } = response; 51 | 52 | let embed = {fields: []}; 53 | embed.color = 12277111; 54 | embed.author = { 55 | url: `https://osu.ppy.sh/u/${user.id}`, 56 | name: `${user.username} – ${Number(user.statistics.pp).toFixed(2)}pp (#${Number(user.statistics.global_rank).toLocaleString()})`, 57 | icon_url: user.avatar_url 58 | }; 59 | 60 | embed.thumbnail = { 61 | url: firsts[0].beatmapset.covers.list 62 | }; 63 | 64 | embed.fields = []; 65 | 66 | for(const first of firsts){ 67 | let name = `${first.rank_emoji} ${first.stars.toFixed(2)}★ ${first.beatmapset.artist} - ${first.beatmapset.title} [${first.beatmap.version}]`; 68 | 69 | if(first.mods.length > 0) 70 | name += ` +${first.mods.map(mod => mod.acronym).join(",")}`; 71 | 72 | name += ` ${first.accuracy}%`; 73 | 74 | let value = `[🔗](https://osu.ppy.sh/b/${first.beatmap.id}) `; 75 | 76 | if(Number(first.max_combo) < first.beatmap.max_combo && first.pp_fc > first.pp) 77 | value += `**${Number(first.pp).toFixed(2)}pp** ➔ ${first.pp_fc.toFixed(2)}pp for ${first.acc_fc}% FC${helper.sep}`; 78 | else 79 | value += `**${Number(first.pp).toFixed(2)}pp**${helper.sep}` 80 | 81 | if(Number(first.max_combo) < first.beatmap.max_combo) 82 | value += `${first.max_combo}/${first.beatmap.max_combo}x`; 83 | else 84 | value += `${first.max_combo}x`; 85 | 86 | if(Number(first.statistics.ok ?? 0) > 0 || Number(first.statistics.meh ?? 0) > 0 || Number(first.statistics.miss ?? 0) > 0) 87 | value += helper.sep; 88 | 89 | if(Number(first.statistics.ok ?? 0) > 0) 90 | value += `${first.statistics.ok}x100`; 91 | 92 | if(Number(first.statistics.meh ?? 0) > 0){ 93 | if(Number(first.statistics.ok ?? 0) > 0) value += helper.sep; 94 | value += `${first.statistics.meh ?? 0}x50`; 95 | } 96 | 97 | if(Number(first.statistics.miss ?? 0) > 0){ 98 | if(Number(first.statistics.ok ?? 0) > 0 || Number(first.statistics.meh ?? 0) > 0) value += helper.sep; 99 | value += `${first.statistics.miss ?? 0}xMiss`; 100 | } 101 | 102 | value += `${helper.sep}` 103 | 104 | embed.fields.push({ name, value }) 105 | } 106 | 107 | resolve({ embed }); 108 | } 109 | }) 110 | } 111 | }) 112 | } 113 | }; 114 | -------------------------------------------------------------------------------- /commands/flowa.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const helper = require('../helper.js'); 3 | const config = require('../config.json'); 4 | const FLOWA_MAX = 1000; 5 | 6 | const pexels_api = axios.create({ 7 | baseURL: 'https://api.pexels.com/v1/' 8 | }); 9 | 10 | module.exports = { 11 | command: 'flowa', 12 | description: "Show a random flower picture. Images from .", 13 | usage: '[optional tags separated by space]', 14 | example: { 15 | run: 'flowa sakura tree', 16 | result: "Returns a random picture of a sakura tree." 17 | }, 18 | configRequired: ['credentials.pexels_key'], 19 | call: obj => { 20 | return new Promise((resolve, reject) => { 21 | let { argv } = obj; 22 | let query = 'flower nature'; 23 | let max = FLOWA_MAX; 24 | 25 | if(argv.length > 1){ 26 | query += ' ' + argv.slice(1).join(' '); 27 | max = 50; 28 | } 29 | 30 | pexels_api.get( 31 | 'search', 32 | { 33 | params: { 34 | query: query, 35 | per_page: 1, 36 | page: helper.getRandomInt(1, max) 37 | }, 38 | headers: { 39 | 'Authorization': config.credentials.pexels_key 40 | } 41 | } 42 | ).then(response => { 43 | let photos = response.data.photos; 44 | 45 | if(photos.length == 0) 46 | reject("No results"); 47 | else 48 | resolve(response.data.photos[0].src.original); 49 | }).catch(err => { 50 | helper.error(err); 51 | reject("Couldn't connect to Pexels API") 52 | }); 53 | }); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /commands/flowabot.js: -------------------------------------------------------------------------------- 1 | const config = require('../config.json'); 2 | 3 | module.exports = { 4 | command: 'flowabot', 5 | description: "Show information about this bot.", 6 | configRequired: ['prefix'], 7 | call: obj => { 8 | let embed = { 9 | description: "Modular Discord bot with various features including twitch commands and advanced osu! commands.", 10 | url: "https://github.com/LeaPhant/flowabot", 11 | color: 12277111, 12 | footer: { 13 | icon_url: "https://avatars1.githubusercontent.com/u/14080165?s=64&v=2", 14 | text: "LeaPhant" 15 | }, 16 | thumbnail: { 17 | url: "https://raw.githubusercontent.com/LeaPhant/flowabot/master/res/logo.png" 18 | }, 19 | author: { 20 | name: "flowabot", 21 | url: "https://github.com/LeaPhant/flowabot" 22 | }, 23 | fields: [ 24 | { 25 | name: "GitHub Repo", 26 | value: "https://github.com/LeaPhant/flowabot" 27 | }, 28 | { 29 | name: "mikazuki fork", 30 | value: "https://github.com/respektive/flowabot" 31 | }, 32 | { 33 | name: "Commands", 34 | value: "https://github.com/respektive/flowabot/blob/master/COMMANDS.md" 35 | }, 36 | { 37 | name: "Prefix", 38 | value: `The command prefix on this server is \`${config.prefix}\`.` 39 | } 40 | ] 41 | }; 42 | 43 | return {embed: embed}; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /commands/help.js: -------------------------------------------------------------------------------- 1 | const helper = require('../helper.js'); 2 | const config = require('../config.json'); 3 | 4 | module.exports = { 5 | command: 'help', 6 | argsRequired: 1, 7 | description: [ 8 | "Get help for a command.", 9 | "", 10 | "**List of all commands:** https://github.com/respektive/flowabot/blob/master/COMMANDS.md" 11 | ], 12 | usage: '', 13 | example: [ 14 | { 15 | run: "help pp", 16 | result: `Returns help on how to use the \`${config.prefix}pp\` command.` 17 | } 18 | ], 19 | call: obj => { 20 | let { argv } = obj; 21 | return helper.commandHelp(argv[1]); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /commands/ign-set.js: -------------------------------------------------------------------------------- 1 | const helper = require('../helper.js'); 2 | 3 | module.exports = { 4 | command: 'ign-set', 5 | description: "Sets your osu! username so you can use osu! commands without specifying a username.", 6 | usage: '', 7 | example: { 8 | run: "ign-set nathan on osu", 9 | result: "Sets your osu! username to nathan on osu." 10 | }, 11 | call: obj => { 12 | return new Promise((resolve, reject) => { 13 | let { msg, user_ign } = obj; 14 | 15 | let split = helper.splitWithTail(msg.content, ' ', 1); 16 | 17 | if(split.length < 2){ 18 | reject(helper.commandHelp('ign-set')); 19 | return false; 20 | } 21 | 22 | let ign = split[1].replace(/\+/g, " "); 23 | let user_id = msg.author.id; 24 | 25 | if(ign.length == 0){ 26 | reject(helper.commandHelp('ign-set')); 27 | return false; 28 | } 29 | 30 | if(!helper.validUsername(ign)){ 31 | reject('Not a valid osu! username!'); 32 | return false; 33 | } 34 | 35 | user_ign[user_id] = ign; 36 | helper.setItem('user_ign', JSON.stringify(user_ign)); 37 | 38 | let author = msg.author.username.endsWith('s') ? 39 | `${msg.author.username}'`: `${msg.author.username}'s`; 40 | 41 | msg.channel.send(`${author} ingame name set to ${ign}`); 42 | }); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /commands/lastfm.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const helper = require('../helper.js'); 4 | const config = require('../config.json'); 5 | const { DateTime } = require('luxon'); 6 | 7 | const lastFm = axios.create({ 8 | baseURL: 'http://ws.audioscrobbler.com/2.0/', 9 | params: { 10 | format: 'json' 11 | } 12 | }); 13 | 14 | const periods = { 15 | '7day': { 16 | name: 'Stats for the last 7 days', 17 | time: 7 * 24 * 60 * 60 18 | }, 19 | '1month': { 20 | name: 'Stats for the last 30 days', 21 | time: 30 * 24 * 60 * 60 22 | }, 23 | '3month': { 24 | name: 'Stats for the last 3 months', 25 | time: 90 * 24 * 60 * 60 26 | }, 27 | '6month': { 28 | name: 'Stats for the last 6 months', 29 | time: 180 * 24 * 60 * 60 30 | }, 31 | '12month': { 32 | name: 'Stats for the last year', 33 | time: 360 * 24 * 60 * 60 34 | }, 35 | 'overall': { 36 | name: 'Stats of all time', 37 | time: Number.MAX_SAFE_INTEGER 38 | } 39 | }; 40 | 41 | module.exports = { 42 | command: 'lastfm', 43 | description: "Show Last.fm stats for a user.", 44 | argsRequired: 1, 45 | usage: ` [period (${Object.keys(periods).join(', ')})]`, 46 | example: { 47 | run: 'lastfm rj overall', 48 | result: "Returns total last.fm stats for rj." 49 | }, 50 | configRequired: ['credentials.last_fm_key'], 51 | call: obj => { 52 | return new Promise((resolve, reject) => { 53 | let { argv, msg } = obj; 54 | let period = '1month'; 55 | 56 | lastFm.defaults.params.api_key = config.credentials.last_fm_key; 57 | 58 | if(argv.length > 2){ 59 | if(Object.keys(periods).includes(argv[2])){ 60 | period = argv[2]; 61 | }else{ 62 | msg.channel.send(`Invalid time period! (\`${Object.keys(periods).join(', ')})`); 63 | return false; 64 | } 65 | } 66 | 67 | let requests = [ 68 | lastFm.get('', { params: { method: 'user.getinfo', user: argv[1] }}), 69 | lastFm.get('', { params: { method: 'user.gettopartists', limit: 4, user: argv[1], period: period }}), 70 | lastFm.get('', { params: { method: 'user.gettoptracks', limit: 4, user: argv[1], period: period }}), 71 | lastFm.get('', { params: { method: 'user.getrecenttracks', limit: 2, user: argv[1], from: Math.floor(DateTime.now().toSeconds()) - periods[period].time }}) 72 | ]; 73 | 74 | Promise.all(requests).then(response => { 75 | let user = response[0].data.user; 76 | let top_artists = response[1].data.topartists; 77 | let top_tracks = response[2].data.toptracks; 78 | let recent_tracks = response[3].data.recenttracks; 79 | 80 | let recent_tracks_string = ""; 81 | let top_artists_string = ""; 82 | let top_tracks_string = ""; 83 | 84 | recent_tracks.track.forEach((track, index) => { 85 | if(index > 0) 86 | recent_tracks_string += "\n"; 87 | let track_date; 88 | if(track["@attr"] != undefined && track["@attr"].nowplaying == 'true') 89 | track_date = "now playing"; 90 | else 91 | track_date = DateTime.fromSeconds(track.date.uts).toRelative(); 92 | recent_tracks_string += `**${track.artist["#text"]}** – ${track.name} *(${track_date})*`; 93 | }); 94 | 95 | top_artists.artist.forEach((artist, index) => { 96 | if(index > 0) 97 | top_artists_string += "\n"; 98 | top_artists_string += `${artist.name} ▸ ${artist.playcount}`; 99 | }); 100 | 101 | top_tracks.track.forEach((track, index) => { 102 | if(index > 0) 103 | top_tracks_string += "\n"; 104 | top_tracks_string += `**${track.artist.name}** – ${track.name} ▸ ${track.playcount}`; 105 | }); 106 | 107 | if(top_artists.artist.length == 0) 108 | top_artists_string = 'No scrobbles in the selected timeframe'; 109 | 110 | if(top_tracks.track.length == 0) 111 | top_tracks_string = 'No scrobbles in the selected timeframe'; 112 | 113 | let embed = { 114 | color: 13959168, 115 | description: periods[period].name, 116 | footer: { 117 | icon_url: "https://cdn.discordapp.com/attachments/532034792804581379/591679254656319556/lastfm-1.png", 118 | text: `Last.fm${helper.sep}Scrobbling since ${DateTime.fromSeconds(user.registered.unixtime).toFormat('dd MMMM yyyy')}` 119 | }, 120 | thumbnail: { 121 | url: user.image["2"]["#text"] 122 | }, 123 | author: { 124 | name: user.name, 125 | url: user.url, 126 | icon_url: user.image["0"]["#text"] 127 | } 128 | }; 129 | 130 | if(recent_tracks.track.length == 0 || top_artists.artist.length == 0 || top_tracks.track.length == 0){ 131 | embed.fields = [ 132 | { 133 | name: "Total Scrobbles", 134 | value: user.playcount 135 | }, 136 | { 137 | name: "Recent Tracks", 138 | value: "No scrobbles for the selected timeframe" 139 | } 140 | ]; 141 | }else{ 142 | embed.fields = [ 143 | { 144 | name: "Scrobbles", 145 | value: recent_tracks["@attr"].total 146 | }, 147 | { 148 | name: "Recent Tracks", 149 | value: recent_tracks_string 150 | }, 151 | { 152 | name: "Top Artists", 153 | value: top_artists_string 154 | }, 155 | { 156 | name: "Top Songs", 157 | value: top_tracks_string 158 | } 159 | ]; 160 | } 161 | 162 | resolve({embed: embed}); 163 | }).catch(err => { 164 | if(config.debug) 165 | helper.error(err); 166 | 167 | reject('User not found'); 168 | }); 169 | }); 170 | } 171 | }; 172 | -------------------------------------------------------------------------------- /commands/lazerscore.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const mods_enum = { 4 | '' : 0, 5 | 'NF' : 1, 6 | 'EZ' : 2, 7 | 'TD' : 4, 8 | 'HD' : 8, 9 | 'HR' : 16, 10 | 'SD' : 32, 11 | 'DT' : 64, 12 | 'RX' : 128, 13 | 'HT' : 256, 14 | 'NC' : 512, 15 | 'FL' : 1024, 16 | 'AT' : 2048, 17 | 'SO' : 4096, 18 | 'AP' : 8192, 19 | 'PF' : 16384, 20 | '4K' : 32768, 21 | '5K' : 65536, 22 | '6K' : 131072, 23 | '7K' : 262144, 24 | '8K' : 524288, 25 | 'FI' : 1048576, 26 | 'RD' : 2097152, 27 | 'LM' : 4194304, 28 | '9K' : 16777216, 29 | '10K' : 33554432, 30 | '1K' : 67108864, 31 | '3K' : 134217728, 32 | '2K' : 268435456, 33 | 'V2' : 536870912, 34 | }; 35 | 36 | 37 | function modsMultiplier(mods) { 38 | let multiplier = 1.0; 39 | if (mods.includes("NF")) 40 | multiplier *= 0.5; 41 | if (mods.includes("EZ")) 42 | multiplier *= 0.5; 43 | if (mods.includes("HT")) 44 | multiplier *= 0.3; 45 | if (mods.includes("HD")) 46 | multiplier *= 1.06; 47 | if (mods.includes("HR")) 48 | multiplier *= 1.06; 49 | if (mods.includes("DT")) 50 | multiplier *= 1.12; 51 | if (mods.includes("FL")) 52 | multiplier *= 1.12; 53 | if (mods.includes("SO")) 54 | multiplier *= 0.9; 55 | if ((mods.includes("RX")) || (mods.includes("AP"))) 56 | multiplier *= 0; 57 | return multiplier; 58 | } 59 | 60 | module.exports = { 61 | command: ['lazerscore', 'ls', 'classicscore'], 62 | description: "Calculate maximum lazer classic score for a beatmap.", 63 | argsRequired: 1, 64 | usage: ' [+mods]', 65 | example: [ 66 | { 67 | run: "ls https://osu.ppy.sh/b/75", 68 | result: "Returns the maximum lazer classic score for Disco Prince with no mods." 69 | }, 70 | { 71 | run: "classicscore https://osu.ppy.sh/b/75 +HDHRDT", 72 | result: "Returns the maximum lazer classic score for Disco Prince +HDHRDT." 73 | } 74 | ], 75 | call: obj => { 76 | return new Promise((resolve, reject) => { 77 | let { argv } = obj; 78 | 79 | let beatmap_url = argv[1]; 80 | let mods = argv[2]; 81 | if(mods){ 82 | if(mods.startsWith("+")){ 83 | mods = mods.substring(1); 84 | } 85 | } else { 86 | mods = "NM" 87 | } 88 | mods = mods.toUpperCase(); 89 | 90 | if(beatmap_url.includes("#osu/")) 91 | beatmap_id = parseInt(beatmap_url.split("#osu/").pop()); 92 | else if(beatmap_url.includes("#fruits/")) 93 | beatmap_id = parseInt(beatmap_url.split("#fruits/").pop()); 94 | else if(beatmap_url.includes("#taiko/")) 95 | beatmap_id = parseInt(beatmap_url.split("#taiko/").pop()); 96 | else if(beatmap_url.includes("#mania/")) 97 | beatmap_id = parseInt(beatmap_url.split("#mania/").pop()); 98 | else if(beatmap_url.includes("/b/")) 99 | beatmap_id = parseInt(beatmap_url.split("/b/").pop()); 100 | else if(beatmap_url.includes("/osu/")) 101 | beatmap_id = parseInt(beatmap_url.split("/osu/").pop()); 102 | else if(beatmap_url.includes("/beatmaps/")) 103 | beatmap_id = parseInt(beatmap_url.split("/beatmaps/").pop()); 104 | else if(beatmap_url.includes("/discussion/")) 105 | beatmap_id = parseInt(beatmap_url.split("/discussion/").pop().split("/")[0]); 106 | else if(parseInt(beatmap_url) == beatmap_url && _id_only) 107 | beatmap_id = parseInt(beatmap_url); 108 | 109 | if(beatmap_id === NaN){ 110 | reject('Invalid beatmap url'); 111 | } 112 | 113 | axios.get(`https://osu.respektive.pw/b/${beatmap_id}`).then(response => { 114 | let beatmap = response.data.beatmap; 115 | 116 | let mod_multiplier, output, score; 117 | 118 | mod_multiplier = modsMultiplier(mods.match(/.{1,2}/g)); 119 | score = parseInt(Math.pow(mod_multiplier * beatmap.hit_objects, 2) * 32.57 + 100000); 120 | output = "Max lazer classic score (" + mods + "): " + score.toLocaleString(); 121 | 122 | resolve(output); 123 | 124 | }); 125 | }); 126 | } 127 | }; 128 | -------------------------------------------------------------------------------- /commands/level.js: -------------------------------------------------------------------------------- 1 | const osu = require("../osu.js"); 2 | const helper = require("../helper.js"); 3 | 4 | function calculateLevel(user_stats) { 5 | const xp_values = { 6 | medal_count: 10000, 7 | ss_count: 100, 8 | s_count: 50, 9 | a_count: 25, 10 | ranked_score: 0.000002, 11 | total_score: 0.000002, 12 | pp: 250, 13 | playtime: 0.055, 14 | }; 15 | 16 | let xp = { 17 | medal_count: 0, 18 | ss_count: 0, 19 | s_count: 0, 20 | a_count: 0, 21 | ranked_score: 0, 22 | total_score: 0, 23 | pp: 0, 24 | playtime: 0, 25 | }; 26 | 27 | let total_xp = 0; 28 | let level = 0; 29 | 30 | for (const [key, value] of Object.entries(user_stats)) { 31 | xp[key] = value * xp_values[key]; 32 | total_xp += value * xp_values[key]; 33 | } 34 | 35 | level = 36 | (Math.sqrt(total_xp ** 2 - 8100 * total_xp - 2116800) + 37 | total_xp - 38 | 4050) ** 39 | (1 / 3) / 40 | 10 ** (1 / 3) + 41 | (57 * 10) ** (1 / 3) / 42 | (Math.sqrt(total_xp ** 2 - 8100 * total_xp - 2116800) + 43 | total_xp - 44 | 4050) ** 45 | (1 / 3) - 46 | 8; 47 | 48 | return { level, total_xp, xp }; 49 | } 50 | 51 | module.exports = { 52 | command: "level", 53 | description: "Calculate experimental level.", 54 | startsWith: true, 55 | usage: "[username]", 56 | example: [ 57 | { 58 | run: "level", 59 | result: "Calculates your exerimental level.", 60 | }, 61 | { 62 | run: "level mrekk", 63 | result: "Calculates mrekks experimental level", 64 | }, 65 | ], 66 | configRequired: ["credentials.client_id", "credentials.client_secret"], 67 | call: (obj) => { 68 | return new Promise((resolve, reject) => { 69 | let { argv, msg, user_ign } = obj; 70 | 71 | let level_user = helper.getUsername(argv, msg, user_ign); 72 | 73 | let match = argv[0].match(/\d+/); 74 | 75 | if (match != null && !isNaN(match[0])) 76 | count = Math.max(1, Math.min(match[0], 25)); 77 | 78 | if (!level_user) { 79 | if (user_ign[msg.author.id] == undefined) { 80 | reject(helper.commandHelp("ign-set")); 81 | } else { 82 | reject(helper.commandHelp("level")); 83 | } 84 | 85 | return false; 86 | } else { 87 | osu.get_users({ user: level_user }, (err, response) => { 88 | if (err) { 89 | helper.error(err); 90 | reject(err); 91 | } else { 92 | const { users, medal_count } = response; 93 | 94 | const user = users[0]; 95 | 96 | let user_stats = { 97 | medal_count, 98 | ss_count: 0, 99 | s_count: 0, 100 | a_count: 0, 101 | ranked_score: 0, 102 | total_score: 0, 103 | pp: 0, 104 | playtime: 0, 105 | }; 106 | 107 | for (const [key, value] of Object.entries( 108 | user.statistics_rulesets 109 | )) { 110 | user_stats.ss_count += 111 | value.grade_counts.ss + value.grade_counts.ssh; 112 | user_stats.s_count += 113 | value.grade_counts.s + value.grade_counts.sh; 114 | user_stats.a_count += value.grade_counts.a; 115 | user_stats.ranked_score += value.ranked_score; 116 | user_stats.total_score += value.total_score; 117 | user_stats.pp += value.pp; 118 | user_stats.playtime += value.play_time; 119 | } 120 | 121 | const level = calculateLevel(user_stats); 122 | 123 | let response_text = `${ 124 | user.username 125 | }:\nLv${level.level.toFixed(3)} (${Math.floor( 126 | level.total_xp 127 | )} XP)\n`; 128 | 129 | response_text += "```json\n"; 130 | response_text += JSON.stringify(level.xp, null, 2); 131 | response_text += "\n```"; 132 | 133 | resolve(response_text); 134 | } 135 | }); 136 | } 137 | }); 138 | }, 139 | }; 140 | -------------------------------------------------------------------------------- /commands/np.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const { DateTime } = require('luxon'); 3 | 4 | const helper = require('../helper.js'); 5 | const config = require('../config.json'); 6 | 7 | const lastFm = axios.create({ 8 | baseURL: 'http://ws.audioscrobbler.com/2.0/', 9 | params: { 10 | format: 'json' 11 | } 12 | }); 13 | 14 | module.exports = { 15 | command: 'np', 16 | description: "Shows what song you are currently listening to. If it can't be retrieved from Rich Presence it will ask for a Last.fm username.", 17 | usage: '[last.fm username]', 18 | configRequired: ['credentials.last_fm_key'], 19 | call: obj => { 20 | return new Promise((resolve, reject) => { 21 | let { argv, msg } = obj; 22 | 23 | let activities = msg.author.presence.activities; 24 | 25 | let embed; 26 | 27 | for(const presence of activities){ 28 | if(presence.name !== null 29 | && ['Spotify', 'osu!'].includes(presence.name)){ 30 | if(presence.name == 'osu!' && presence.details != null){ 31 | let artist_title = presence.details; 32 | let username = presence.assets.largeText; 33 | let profile_link; 34 | 35 | if(username.includes('(')) 36 | profile_link = `https://osu.ppy.sh/u/${username.split('(')[0].trim()}`; 37 | 38 | let playing_text = presence.state.startsWith('Spectating') ? 39 | presence.state : 'Playing'; 40 | 41 | 42 | embed = { 43 | color: 12277111, 44 | author: { 45 | name: msg.member.nickname || msg.member.username, 46 | icon_url: msg.author.avatarURL() 47 | }, 48 | title: artist_title, 49 | footer: { 50 | icon_url: "https://osu.ppy.sh/favicon-32x32.png", 51 | text: `osu!${helper.sep}${playing_text} right now` 52 | } 53 | }; 54 | 55 | if(profile_link) 56 | embed.author.url = profile_link; 57 | }else{ 58 | let title = presence.details; 59 | let artist = presence.state; 60 | let album = presence.assets; 61 | let album_name = album.largeText; 62 | let album_cover = album.largeImage.split(':'); 63 | let track_url = `https://open.spotify.com/track/${presence.syncID}`; 64 | let username = msg.author.username; 65 | 66 | if(msg.member !== null && msg.member.nickname !== null) 67 | username = msg.member.nickname; 68 | 69 | if(album_cover.length > 1){ 70 | album_cover = `https://i.scdn.co/image/${album_cover[1]}`; 71 | 72 | embed = { 73 | color: 1947988, 74 | author: { 75 | name: username, 76 | icon_url: msg.author.avatarURL() 77 | }, 78 | footer: { 79 | icon_url: "https://cdn.discordapp.com/attachments/572429763700981780/807009451173216277/favicon-1.png", 80 | text: `Spotify${helper.sep}Listening right now` 81 | }, 82 | thumbnail: { 83 | url: album_cover 84 | }, 85 | title: `**${artist}** – ${title}`, 86 | description: `Album: **${album_name}**`, 87 | url: track_url 88 | } 89 | } 90 | } 91 | } 92 | } 93 | 94 | if(embed){ 95 | resolve({embed: embed}); 96 | return true; 97 | } 98 | 99 | if(argv.length < 2){ 100 | reject('Currently not sharing any listening status. Please specify a Last.fm username.') 101 | return false; 102 | } 103 | 104 | lastFm.defaults.params.api_key = config.credentials.last_fm_key; 105 | 106 | let requests = [ 107 | lastFm.get('', { params: { method: 'user.getinfo', user: argv[1] }}), 108 | lastFm.get('', { params: { method: 'user.getrecenttracks', user: argv[1], limit: 1 }}) 109 | ]; 110 | 111 | Promise.all(requests).then(response => { 112 | let user = response[0].data.user; 113 | let recent_tracks = response[1].data.recenttracks; 114 | if(recent_tracks.track.length == 0){ 115 | reject(`This user hasn't listened to anything yet`); 116 | }else{ 117 | let listening_text = ""; 118 | let track = recent_tracks.track[0]; 119 | 120 | if(track["@attr"] != undefined && track["@attr"].nowplaying == 'true'){ 121 | listening_text = 'Listening right now'; 122 | }else{ 123 | listening_text = `Listened ${DateTime.fromSeconds(track.date.uts).toRelative()}`; 124 | } 125 | 126 | embed = { 127 | color: 13959168, 128 | footer: { 129 | icon_url: "https://cdn.discordapp.com/attachments/532034792804581379/591679254656319556/lastfm-1.png", 130 | text: `Last.fm${helper.sep}${listening_text}` 131 | }, 132 | thumbnail: { 133 | url: track.image[2]["#text"] 134 | }, 135 | title: `**${track.artist["#text"]}** – ${track.name}`, 136 | url: track.url, 137 | author: { 138 | name: user.name, 139 | url: user.url, 140 | icon_url: user.image["0"]["#text"] 141 | } 142 | }; 143 | 144 | if(track.album["#text"].length > 0) 145 | embed.description = `⠀\nAlbum: **${track.album["#text"]}**` 146 | 147 | resolve({ embed: embed }); 148 | } 149 | }).catch(err => { 150 | if(config.debug) 151 | helper.error(err); 152 | 153 | reject('User not found'); 154 | }); 155 | }); 156 | } 157 | }; 158 | -------------------------------------------------------------------------------- /commands/oppai.js: -------------------------------------------------------------------------------- 1 | const { execFileSync, execFile, exec } = require('child_process'); 2 | const fs = require('fs').promises; 3 | const path = require('path'); 4 | const os = require('os'); 5 | const URL = require('url'); 6 | 7 | const helper = require('../helper.js'); 8 | const osu = require('../osu.js'); 9 | const config = require('../config.json'); 10 | 11 | function parseLine(line, decimals){ 12 | let output = parseFloat(line.split(": ").pop()); 13 | 14 | if(decimals >= 0) 15 | return +output.toFixed(decimals); 16 | 17 | return output; 18 | } 19 | 20 | module.exports = { 21 | command: 'oppai', 22 | description: "Uses oppai (2016 ppv2) to calculate pp for a beatmap.", 23 | argsRequired: 1, 24 | usage: ' [+HDDT] [99.23%] [2x100] [1x50] [3m] [342x]', 25 | example: { 26 | run: "oppai https://osu.ppy.sh/b/75 +DT ", 27 | result: "Calculates pp on this beatmap with DT applied." 28 | }, 29 | configRequired: ['debug', 'osu_cache_path'], 30 | call: async obj => { 31 | return new Promise((resolve, reject) => { 32 | let { argv, msg, last_beatmap } = obj; 33 | 34 | let beatmap_url = argv[1]; 35 | let mods = []; 36 | let download_path, download_promise; 37 | 38 | if(beatmap_url.startsWith('<') && beatmap_url.endsWith('>')) 39 | beatmap_url = beatmap_url.substring(1, beatmap_url.length - 1); 40 | 41 | osu.parse_beatmap_url(beatmap_url, true).then(response => { 42 | let beatmap_id = response; 43 | 44 | if(!beatmap_id){ 45 | let download_url = URL.parse(beatmap_url); 46 | download_path = path.resolve(os.tmpdir(), `${Math.floor(Math.random() * 1000000) + 1}.osu`); 47 | 48 | download_promise = helper.downloadFile(download_path, download_url); 49 | download_promise.catch(reject); 50 | } 51 | 52 | Promise.resolve(download_promise).then(async () => { 53 | if(beatmap_id === undefined && download_path === undefined){ 54 | reject('Invalid beatmap url'); 55 | return false; 56 | } 57 | 58 | let beatmap_path = download_path ? download_path : path.resolve(config.osu_cache_path, `${beatmap_id}.osu`); 59 | 60 | oppaiCmd = config.oppai_path + ' ' + beatmap_path + ' ' + argv.slice(2).join(" ") 61 | 62 | exec(oppaiCmd, (err, stdout, stderr) => { 63 | if(err || stderr){ 64 | if(err){ 65 | helper.error(err); 66 | reject(err); 67 | return false; 68 | } 69 | 70 | let error = stderr.split("\n")[1]; 71 | reject(error); 72 | 73 | if(config.debug) 74 | helper.error(stderr); 75 | 76 | return false; 77 | }else{ 78 | let ppResult = stdout; 79 | 80 | let output = `\`\`\`\n${ppResult}\`\`\``; 81 | 82 | if(beatmap_id){ 83 | helper.updateLastBeatmap({ 84 | beatmap_id, 85 | mods, 86 | fail_percent: last_beatmap[msg.channel.id].fail_percent || 1, 87 | acc: last_beatmap[msg.channel.id].acc || 100 88 | }, msg.channel.id, last_beatmap); 89 | } 90 | 91 | resolve(output); 92 | } 93 | }); 94 | }); 95 | }); 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /commands/oppai2.js: -------------------------------------------------------------------------------- 1 | const { execFileSync, execFile, exec } = require('child_process'); 2 | const fs = require('fs').promises; 3 | const path = require('path'); 4 | const os = require('os'); 5 | const URL = require('url'); 6 | 7 | const helper = require('../helper.js'); 8 | const osu = require('../osu.js'); 9 | const config = require('../config.json'); 10 | 11 | function parseLine(line, decimals){ 12 | let output = parseFloat(line.split(": ").pop()); 13 | 14 | if(decimals >= 0) 15 | return +output.toFixed(decimals); 16 | 17 | return output; 18 | } 19 | 20 | module.exports = { 21 | command: 'oppai2', 22 | description: "Uses oppai (2014 ppv2) to calculate pp for a beatmap.", 23 | argsRequired: 1, 24 | usage: ' [+HDDT] [99.23%] [2x100] [1x50] [3m] [342x]', 25 | example: { 26 | run: "oppai https://osu.ppy.sh/b/75 +DT ", 27 | result: "Calculates pp on this beatmap with DT applied." 28 | }, 29 | configRequired: ['debug', 'osu_cache_path'], 30 | call: async obj => { 31 | return new Promise((resolve, reject) => { 32 | let { argv, msg, last_beatmap } = obj; 33 | 34 | let beatmap_url = argv[1]; 35 | let mods = []; 36 | let download_path, download_promise; 37 | 38 | if(beatmap_url.startsWith('<') && beatmap_url.endsWith('>')) 39 | beatmap_url = beatmap_url.substring(1, beatmap_url.length - 1); 40 | 41 | osu.parse_beatmap_url(beatmap_url, true).then(response => { 42 | let beatmap_id = response; 43 | 44 | if(!beatmap_id){ 45 | let download_url = URL.parse(beatmap_url); 46 | download_path = path.resolve(os.tmpdir(), `${Math.floor(Math.random() * 1000000) + 1}.osu`); 47 | 48 | download_promise = helper.downloadFile(download_path, download_url); 49 | download_promise.catch(reject); 50 | } 51 | 52 | Promise.resolve(download_promise).then(async () => { 53 | if(beatmap_id === undefined && download_path === undefined){ 54 | reject('Invalid beatmap url'); 55 | return false; 56 | } 57 | 58 | let beatmap_path = download_path ? download_path : path.resolve(config.osu_cache_path, `${beatmap_id}.osu`); 59 | 60 | oppaiCmd = '/home/osu/oppai2014/oppai-ng/oppai ' + beatmap_path + ' ' + argv.slice(2).join(" ") 61 | 62 | exec(oppaiCmd, (err, stdout, stderr) => { 63 | if(err || stderr){ 64 | if(err){ 65 | helper.error(err); 66 | reject(err); 67 | return false; 68 | } 69 | 70 | let error = stderr.split("\n")[1]; 71 | reject(error); 72 | 73 | if(config.debug) 74 | helper.error(stderr); 75 | 76 | return false; 77 | }else{ 78 | let ppResult = stdout 79 | 80 | let output = `\`\`\`\n${ppResult}\`\`\``; 81 | 82 | if(beatmap_id){ 83 | helper.updateLastBeatmap({ 84 | beatmap_id, 85 | mods, 86 | fail_percent: last_beatmap[msg.channel.id].fail_percent || 1, 87 | acc: last_beatmap[msg.channel.id].acc || 100 88 | }, msg.channel.id, last_beatmap); 89 | } 90 | 91 | resolve(output); 92 | } 93 | }); 94 | }); 95 | }); 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /commands/osu-track.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: 'osu-track', 6 | description: "Start tracking the specified user's osu! top plays in the current channel.", 7 | argsRequired: 1, 8 | permsRequired: ['MANAGE_MESSAGES'], 9 | usage: ' [top play limit (1-100, default 50)]', 10 | example: { 11 | run: "osu-track nathan_on_osu 50", 12 | result: "Start tracking nathan on osu's top 50 top plays." 13 | }, 14 | configRequired: ["credentials.client_id", "credentials.client_secret"], 15 | call: obj => { 16 | return new Promise((resolve, reject) => { 17 | let { argv, msg, user_ign } = obj; 18 | 19 | let osu_name = helper.getUsername(argv.slice(0, 2), msg, user_ign); 20 | let top = 50; 21 | 22 | if(argv.length > 2){ 23 | let _top = parseInt(argv[2]); 24 | if(_top >= 1 && _top <= 100){ 25 | top = _top; 26 | }else{ 27 | return false; 28 | reject(helper.commandHelp('osu-track')); 29 | } 30 | } 31 | 32 | osu.track_user(msg.channel.id, osu_name, top, (err, message) => { 33 | if(err) 34 | reject(err); 35 | else 36 | resolve(message); 37 | }); 38 | }); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /commands/osu-untrack.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: 'osu-untrack', 6 | description: "Stop tracking the specified user's osu! top plays in the current channel.", 7 | argsRequired: 1, 8 | permsRequired: ['MANAGE_MESSAGES'], 9 | usage: ' [top play limit (1-100, default 50)]', 10 | example: { 11 | run: "osu-untrack nathan_on_osu", 12 | result: "Stop tracking nathan on osu's top plays." 13 | }, 14 | configRequired: ["credentials.client_id", "credentials.client_secret"], 15 | call: obj => { 16 | return new Promise((resolve, reject) => { 17 | let { argv, msg, user_ign } = obj; 18 | 19 | let osu_name = helper.getUsername(argv.slice(0, 2), msg, user_ign); 20 | 21 | osu.untrack_user(msg.channel.id, osu_name, (err, message) => { 22 | if(err) 23 | reject(err); 24 | else 25 | resolve(message); 26 | }); 27 | }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /commands/osu.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: ['osu', 'osu2'], 6 | description: "Show osu! stats.", 7 | usage: '[username]', 8 | example: { 9 | run: "osu nathan_on_osu", 10 | result: "Returns nathan on osu's osu! stats." 11 | }, 12 | configRequired: ["credentials.client_id", "credentials.client_secret"], 13 | call: obj => { 14 | return new Promise((resolve, reject) => { 15 | let { argv, msg, user_ign } = obj; 16 | 17 | let extended = argv[0].toLowerCase() == 'osu2'; 18 | 19 | let osu_user = helper.getUsername(argv, msg, user_ign); 20 | 21 | if(!osu_user){ 22 | if(user_ign[msg.author.id] == undefined) 23 | reject(helper.commandHelp('ign-set')); 24 | else 25 | reject(helper.commandHelp('osu')); 26 | 27 | return false; 28 | } 29 | 30 | osu.get_user({u: osu_user, extended}, (err, embed) => { 31 | if(err){ 32 | reject(err); 33 | helper.error(err); 34 | return false; 35 | } 36 | 37 | resolve({embed: embed}); 38 | }); 39 | }); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /commands/packs.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | const config = require('../config.json'); 4 | const axios = require('axios'); 5 | const path = require('path'); 6 | const os = require('os'); 7 | const URL = require('url'); 8 | 9 | module.exports = { 10 | command: ['packs', 'pack'], 11 | description: "Get the beatmap packs containing the given beatmap.", 12 | argsRequired: 1, 13 | usage: ' [+mods]', 14 | example: [ 15 | { 16 | run: "packs https://osu.ppy.sh/b/75", 17 | result: "Returns the packs containing the given beatmap Disco Prince." 18 | } 19 | ], 20 | call: obj => { 21 | return new Promise((resolve, reject) => { 22 | let { argv, msg, last_beatmap } = obj; 23 | 24 | let beatmap_url = argv[1]; 25 | 26 | if(beatmap_url.includes("#osu/")) 27 | beatmap_id = parseInt(beatmap_url.split("#osu/").pop()); 28 | else if(beatmap_url.includes("#fruits/")) 29 | beatmap_id = parseInt(beatmap_url.split("#fruits/").pop()); 30 | else if(beatmap_url.includes("#taiko/")) 31 | beatmap_id = parseInt(beatmap_url.split("#taiko/").pop()); 32 | else if(beatmap_url.includes("#mania/")) 33 | beatmap_id = parseInt(beatmap_url.split("#mania/").pop()); 34 | else if(beatmap_url.includes("/b/")) 35 | beatmap_id = parseInt(beatmap_url.split("/b/").pop()); 36 | else if(beatmap_url.includes("/osu/")) 37 | beatmap_id = parseInt(beatmap_url.split("/osu/").pop()); 38 | else if(beatmap_url.includes("/beatmaps/")) 39 | beatmap_id = parseInt(beatmap_url.split("/beatmaps/").pop()); 40 | else if(beatmap_url.includes("/discussion/")) 41 | beatmap_id = parseInt(beatmap_url.split("/discussion/").pop().split("/")[0]); 42 | else if(parseInt(beatmap_url) == beatmap_url && _id_only) 43 | beatmap_id = parseInt(beatmap_url); 44 | 45 | if(beatmap_id === NaN){ 46 | reject('Invalid beatmap url'); 47 | } 48 | 49 | axios.get(`https://osu.respektive.pw/b/${beatmap_id}`).then(response => { 50 | let beatmap = response.data.beatmap; 51 | 52 | let output = ""; 53 | 54 | let packs = beatmap.packs.split(","); 55 | 56 | if(packs[0] != '') { 57 | packs.forEach(pack => { 58 | output += `\n` 59 | }); 60 | } else { 61 | output = "No packs found." 62 | } 63 | 64 | resolve(output); 65 | 66 | }); 67 | }); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /commands/ping.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | command: 'ping', 3 | call: () => { 4 | return "Pong!"; 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /commands/ping2.js: -------------------------------------------------------------------------------- 1 | var tcpp = require('tcp-ping'); 2 | 3 | module.exports = { 4 | command: 'ping2', 5 | description: "ping a website.", 6 | argsRequired: 1, 7 | usage: '', 8 | example: [ 9 | { 10 | run: "ping google.com", 11 | result: "Returns the time it took to ping google.com" 12 | }, 13 | ], 14 | call: obj => { 15 | return new Promise(async (resolve, reject) => { 16 | let { argv } = obj; 17 | let url = argv[1]; 18 | let ping = 0; 19 | 20 | tcpp.probe(url, 80, function(err, available) { 21 | if(available){ 22 | tcpp.ping({ address: url }, function(err, data) { 23 | ping = Math.round(data.avg); 24 | console.log(ping); 25 | resolve(url + ": " + ping + "ms"); 26 | }); 27 | } else { 28 | reject("Couldn't reach this URL"); 29 | } 30 | }); 31 | 32 | }); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /commands/pins.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | const { DateTime } = require('luxon'); 4 | const config = require('../config.json'); 5 | const fetch = require('node-fetch'); 6 | 7 | module.exports = { 8 | command: ['pins', 'pinned'], 9 | description: "Show a list of pinned plays", 10 | startsWith: true, 11 | usage: '[username]', 12 | example: [ 13 | { 14 | run: "pins", 15 | result: "Returns your top 5 pinned plays." 16 | }, 17 | { 18 | run: "pins7 vaxei", 19 | result: "Returns Vaxei's top 7 pinned plays." 20 | } 21 | ], 22 | configRequired: ['credentials.client_id', 'credentials.client_secret'], 23 | call: obj => { 24 | return new Promise((resolve, reject) => { 25 | let { argv, msg, user_ign, last_beatmap } = obj; 26 | 27 | let pin_user = helper.getUsername(argv, msg, user_ign); 28 | 29 | let count = 5; 30 | let match = argv[0].match(/\d+/); 31 | 32 | if(match != null && !isNaN(match[0])) 33 | count = Math.max(1, Math.min(match[0], 25)); 34 | 35 | if(!pin_user){ 36 | if(user_ign[msg.author.id] == undefined){ 37 | reject(helper.commandHelp('ign-set')); 38 | }else{ 39 | reject(helper.commandHelp('pins')); 40 | } 41 | 42 | return false; 43 | }else{ 44 | 45 | osu.get_pins({user: pin_user, count},(err, response) => { 46 | if(err){ 47 | helper.error(err); 48 | reject(err); 49 | }else{ 50 | const { pins, user } = response; 51 | 52 | let embed = {fields: []}; 53 | embed.color = 12277111; 54 | embed.author = { 55 | url: `https://osu.ppy.sh/u/${user.id}`, 56 | name: `${user.username} – ${Number(user.statistics.pp).toFixed(2)}pp (#${Number(user.statistics.global_rank).toLocaleString()})`, 57 | icon_url: user.avatar_url 58 | }; 59 | 60 | embed.thumbnail = { 61 | url: pins[0].beatmapset.covers.list 62 | }; 63 | 64 | embed.fields = []; 65 | 66 | for(const pin of pins){ 67 | let name = `${pin.rank_emoji} ${pin.stars.toFixed(2)}★ ${pin.beatmapset.artist} - ${pin.beatmapset.title} [${pin.beatmap.version}]`; 68 | 69 | if(pin.mods.length > 0) 70 | name += ` +${pin.mods.map(mod => mod.acronym).join(",")}`; 71 | 72 | name += ` ${pin.accuracy}%`; 73 | 74 | let value = `[🔗](https://osu.ppy.sh/b/${pin.beatmap.id})`; 75 | 76 | if(Number(pin.max_combo) < pin.beatmap.max_combo && pin.pp_fc > pin.pp) 77 | value += `**${Number(pin.pp).toFixed(2)}pp** ➔ ${pin.pp_fc.toFixed(2)}pp for ${pin.acc_fc}% FC${helper.sep}`; 78 | else 79 | value += `**${Number(pin.pp).toFixed(2)}pp**${helper.sep}` 80 | 81 | if(Number(pin.max_combo) < pin.beatmap.max_combo) 82 | value += `${pin.max_combo}/${pin.beatmap.max_combo}x`; 83 | else 84 | value += `${pin.max_combo}x`; 85 | 86 | if(Number(pin.statistics.ok ?? 0) > 0 || Number(pin.statistics.meh ?? 0) > 0 || Number(pin.statistics.miss ?? 0) > 0) 87 | value += helper.sep; 88 | 89 | if(Number(pin.statistics.ok ?? 0) > 0) 90 | value += `${pin.statistics.ok}x100`; 91 | 92 | if(Number(pin.statistics.meh ?? 0) > 0){ 93 | if(Number(pin.statistics.ok ?? 0) > 0) value += helper.sep; 94 | value += `${pin.statistics.meh ?? 0}x50`; 95 | } 96 | 97 | if(Number(pin.statistics.miss ?? 0) > 0){ 98 | if(Number(pin.statistics.ok ?? 0) > 0 || Number(pin.statistics.meh ?? 0) > 0) value += helper.sep; 99 | value += `${pin.statistics.miss ?? 0}xMiss`; 100 | } 101 | 102 | value += `${helper.sep}` 103 | 104 | embed.fields.push({ name, value }) 105 | } 106 | 107 | resolve({ embed }); 108 | } 109 | }) 110 | } 111 | }) 112 | } 113 | }; 114 | -------------------------------------------------------------------------------- /commands/query.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | const mysql = require('mysql'); 4 | const { promisify } = require('util'); 5 | const { table } = require('table'); 6 | 7 | const databaseConfig = { 8 | connectionLimit : 10, 9 | host: '127.0.0.1', 10 | user: 'flowabot', 11 | database: 'osu', 12 | timezone: 'utc', 13 | dateStrings : true 14 | }; 15 | 16 | const tableConfig = { 17 | border: { 18 | topBody: `─`, 19 | topJoin: `┬`, 20 | topLeft: `┌`, 21 | topRight: `┐`, 22 | 23 | bottomBody: `─`, 24 | bottomJoin: `┴`, 25 | bottomLeft: `└`, 26 | bottomRight: `┘`, 27 | 28 | bodyLeft: `│`, 29 | bodyRight: `│`, 30 | bodyJoin: `│`, 31 | 32 | joinBody: `─`, 33 | joinLeft: `├`, 34 | joinRight: `┤`, 35 | joinJoin: `┼` 36 | } 37 | }; 38 | 39 | const pool = mysql.createPool(databaseConfig) 40 | 41 | const query = promisify(pool.query).bind(pool); 42 | 43 | module.exports = { 44 | command: 'query', 45 | description: "Run SQL query.", 46 | argsRequired: 1, 47 | usage: '', 48 | call: obj => { 49 | return new Promise(async (resolve, reject) => { 50 | const { argv, msg } = obj; 51 | 52 | let sql = argv.slice(1).join(" "); 53 | let response; 54 | 55 | try{ 56 | response = await query(sql); 57 | }catch(e){ 58 | if(e.sqlMessage) 59 | reject(e.sqlMessage); 60 | else 61 | reject("Error executing query"); 62 | return; 63 | } 64 | 65 | if(response[0] == null){ 66 | reject("No matching entries found"); 67 | return; 68 | } 69 | 70 | const output = []; 71 | 72 | output.push(Object.keys(response[0])); 73 | 74 | for(const row of response) 75 | output.push(Object.values(row)); 76 | 77 | const result = table(output, tableConfig); 78 | 79 | if(result.length > 2000){ 80 | const csvPath = `/opt/flowabot-csv/query-${new Date().toISOString().split('.')[0]}.csv`; 81 | 82 | sql += ` INTO OUTFILE '${csvPath}' 83 | FIELDS TERMINATED BY ',' 84 | ENCLOSED BY '"' 85 | LINES TERMINATED BY '\\n'`; 86 | 87 | console.log(sql); 88 | 89 | console.log(await query(sql)); 90 | 91 | console.log(csvPath); 92 | 93 | resolve({ 94 | files: [ csvPath ] 95 | }); 96 | }else{ 97 | resolve('```\n' + result + '```'); 98 | } 99 | }); 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /commands/recent.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: ['recent', 'rs', 'recentpass', 'rp'], 6 | description: "Show recent score or pass.", 7 | startsWith: true, 8 | usage: '[username]', 9 | example: [ 10 | { 11 | run: "recent nathan_on_osu", 12 | result: "Returns nathan on osu's most recent score." 13 | }, 14 | { 15 | run: "recent3 respektive", 16 | result: "Returns respektive's most recent score." 17 | }, 18 | { 19 | run: "recentpass", 20 | result: "Returns your most recent pass." 21 | } 22 | ], 23 | configRequired: ["credentials.client_id", "credentials.client_secret"], 24 | call: obj => { 25 | return new Promise((resolve, reject) => { 26 | let { argv, msg, user_ign, last_beatmap } = obj; 27 | 28 | let recent_user = helper.getUsername(argv, msg, user_ign); 29 | 30 | let command = argv[0].toLowerCase().replace(/[0-9]/g, ''); 31 | 32 | if(!module.exports.command.includes(command)) 33 | return false; 34 | 35 | let pass = argv[0].toLowerCase().startsWith('rp') || argv[0].toLowerCase().startsWith('recentpass'); 36 | 37 | let index = 1; 38 | let match = argv[0].match(/\d+/); 39 | let _index = match > 0 ? match[0] : 1; 40 | 41 | if(_index >= 1 && _index <= 100) 42 | index = _index; 43 | 44 | if(!recent_user){ 45 | if(user_ign[msg.author.id] == undefined) 46 | reject(helper.commandHelp('ign-set')); 47 | else 48 | reject(helper.commandHelp('recent')); 49 | }else{ 50 | osu.get_recent({user: recent_user, pass: pass, index: index}, (err, recent, strains_bar, ur_promise) => { 51 | if(err){ 52 | helper.error(err); 53 | reject(err); 54 | }else{ 55 | let embed = osu.format_embed(recent); 56 | helper.updateLastBeatmap(recent, msg.channel.id, last_beatmap); 57 | 58 | if(ur_promise){ 59 | resolve({ 60 | embed: embed, 61 | files: [{attachment: strains_bar, name: 'strains_bar.png'}], 62 | edit_promise: new Promise((resolve, reject) => { 63 | ur_promise.then(recent => { 64 | embed = osu.format_embed(recent); 65 | resolve({embed}); 66 | }); 67 | })}); 68 | }else{ 69 | resolve({ 70 | embed: embed, 71 | files: [{attachment: strains_bar, name: 'strains_bar.png'}] 72 | }); 73 | } 74 | } 75 | }); 76 | } 77 | }); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /commands/rosu.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs').promises; 2 | const path = require('path'); 3 | const os = require('os'); 4 | const URL = require('url'); 5 | const { Beatmap, Performance, BeatmapAttributesBuilder } = require('rosu-pp-js'); 6 | 7 | const helper = require('../helper.js'); 8 | const osu = require('../osu.js'); 9 | const config = require('../config.json'); 10 | 11 | function round(num) { 12 | var m = Number((Math.abs(num) * 100).toPrecision(15)); 13 | return Math.round(m) / 100 * Math.sign(num); 14 | } 15 | 16 | function isFloat(value) { 17 | return (!isNaN(value) && value.toString().indexOf('.') != -1) 18 | } 19 | 20 | module.exports = { 21 | command: ['rosu', 'rosu-pp', 'rpp', 'pp'], 22 | description: "Uses rosu-pp to calculate pp for a beatmap.", 23 | argsRequired: 1, 24 | usage: ' [+HDDT] [99.23%] [2x100] [1x50] [3m] [342x] [1.2*] [OD9.5] [AR10.3] [CS6] [HP8]', 25 | example: [ 26 | { 27 | run: "rosu https://osu.ppy.sh/b/75 +HD 4x100 343x CS2", 28 | result: "Calculates pp on this beatmap with HD applied, 4 100s, 343 Combo and CS set to 2." 29 | }, 30 | { 31 | run: "rosu https://osu.ppy.sh/b/774965 99% 1.3*", 32 | result: "Calculates pp on this beatmap with 99% accuracy and a custom speed rate of 1.3*." 33 | } 34 | ], 35 | configRequired: ['debug', 'osu_cache_path'], 36 | call: async obj => { 37 | return new Promise((resolve, reject) => { 38 | let { argv, msg, last_beatmap } = obj; 39 | 40 | 41 | let beatmap_url = argv[1]; 42 | let output = ''; 43 | let mods = []; 44 | let download_path, download_promise; 45 | 46 | let acc_percent, combo, n100, n50, nmiss, od, ar, cs, hp, clock_rate, large_tick_hit, slider_tail_hit; 47 | 48 | if(beatmap_url.startsWith('<') && beatmap_url.endsWith('>')) 49 | beatmap_url = beatmap_url.substring(1, beatmap_url.length - 1); 50 | 51 | for(let i = 2; i < argv.length; ++i){ 52 | if(argv[i].startsWith("+")) 53 | mods = argv[i].substr(1).toLowerCase().match(/.{1,2}/g); 54 | else if(argv[i].endsWith("%")) 55 | acc_percent = parseFloat(argv[i]); 56 | else if(argv[i].endsWith("x")) 57 | if(isFloat(argv[i].slice(0, -1))) { 58 | clock_rate = parseFloat(argv[i]) 59 | } else { 60 | combo = parseInt(argv[i]); 61 | } 62 | else if(argv[i].endsWith("x100")) 63 | n100 = parseInt(argv[i]); 64 | else if(argv[i].endsWith("x50")) 65 | n50 = parseInt(argv[i]); 66 | else if(argv[i].endsWith("m")) 67 | nmiss = parseInt(argv[i]); 68 | else if(argv[i].endsWith("*")) 69 | clock_rate = parseFloat(argv[i]); 70 | else if(argv[i].toLowerCase().startsWith("od")) 71 | od = parseFloat(argv[i].substr(2)); 72 | else if(argv[i].toLowerCase().startsWith("ar")) 73 | ar = parseFloat(argv[i].substr(2)); 74 | else if(argv[i].toLowerCase().startsWith("cs")) 75 | cs = parseFloat(argv[i].substr(2)); 76 | else if(argv[i].toLowerCase().startsWith("hp")) 77 | hp = parseFloat(argv[i].substr(2)); 78 | else if(argv[i].endsWith("L")) 79 | large_tick_hit = parseInt(argv[i]); 80 | else if(argv[i].endsWith("S")) 81 | slider_tail_hit = parseInt(argv[i]); 82 | } 83 | 84 | osu.parse_beatmap_url(beatmap_url, true).then(response => { 85 | let beatmap_id = response; 86 | 87 | if(!beatmap_id){ 88 | let download_url = URL.parse(beatmap_url); 89 | download_path = path.resolve(os.tmpdir(), `${Math.floor(Math.random() * 1000000) + 1}.osu`); 90 | 91 | download_promise = helper.downloadFile(download_path, download_url); 92 | download_promise.catch(reject); 93 | } 94 | 95 | Promise.resolve(download_promise).then(async () => { 96 | if(beatmap_id === undefined && download_path === undefined){ 97 | reject('Invalid beatmap url'); 98 | return false; 99 | } 100 | 101 | let beatmap_path = download_path ? download_path : path.resolve(config.osu_cache_path, `${beatmap_id}.osu`); 102 | 103 | let params = { 104 | } 105 | 106 | if (od) 107 | params.od = od; 108 | 109 | if (ar) 110 | params.ar = ar; 111 | 112 | if (hp) 113 | params.hp = hp; 114 | 115 | if (cs) 116 | params.cs = cs; 117 | 118 | if(mods.length > 0){ 119 | params.mods = mods.join('').toUpperCase(); 120 | } 121 | 122 | if(combo) 123 | params.combo = combo; 124 | 125 | if(n100) 126 | params.n100 = n100; 127 | 128 | if(n50) 129 | params.n50 = n50; 130 | 131 | if(nmiss) 132 | params.misses = nmiss; 133 | 134 | if(acc_percent) 135 | params.accuracy = acc_percent; 136 | 137 | if(clock_rate) 138 | params.clockRate = clock_rate; 139 | 140 | if(large_tick_hit) 141 | params.largeTickHits = large_tick_hit; 142 | 143 | if(slider_tail_hit) 144 | params.sliderEndHits = slider_tail_hit; 145 | 146 | if(beatmap_id){ 147 | helper.updateLastBeatmap({ 148 | beatmap_id, 149 | mods, 150 | fail_percent: last_beatmap[msg.channel.id]?.fail_percent || 1, 151 | acc: last_beatmap[msg.channel.id]?.acc || 100 152 | }, msg.channel.id, last_beatmap); 153 | } 154 | const osuContents = await fs.readFile(beatmap_path, 'utf8'); 155 | const map = new Beatmap(osuContents); 156 | const perf = new Performance(params).calculate(map); 157 | 158 | let pp = round(perf.pp) 159 | let aim_pp = round(perf.ppAim) 160 | let speed_pp = round(perf.ppSpeed) 161 | let acc_pp = round(perf.ppAccuracy) 162 | let fl_pp = '' 163 | let fl_stars = '' 164 | let aim_stars = round(perf.difficulty.aim) 165 | let speed_stars = round(perf.difficulty.speed) 166 | let stars = round(perf.difficulty.stars) 167 | if(mods.includes('fl')) { 168 | fl_pp = `, ${round(perf.ppFlashlight)} flashlight pp` 169 | fl_stars = `, ${round(perf.difficulty.flashlight)} flashlight stars` 170 | } 171 | 172 | let mapAttr = new BeatmapAttributesBuilder({map: map, ...params}).build(); 173 | 174 | ar = round(mapAttr.ar) 175 | od = round(mapAttr.od) 176 | cs = round(mapAttr.cs) 177 | hp = round(mapAttr.hp) 178 | 179 | let bpm = round(map.bpm * mapAttr.clockRate) 180 | 181 | output += `\`\`\`\n${pp}pp (${aim_pp} aim pp, ${speed_pp} speed pp, ${acc_pp} acc pp${fl_pp})\n` 182 | output += `${stars}★ (${aim_stars} aim stars, ${speed_stars} speed stars${fl_stars})\n` 183 | output += `CS${cs} AR${ar} OD${od} HP${hp} ${bpm} BPM` 184 | output += `\`\`\`` 185 | 186 | map.free(); 187 | resolve(output); 188 | 189 | }); 190 | }); 191 | }); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /commands/scamge.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | const MAX_BONUS = 416.6667 * (1 - Math.pow(0.995, 1000)); 5 | 6 | module.exports = { 7 | command: 'scamge', 8 | description: "See how much pp somebody or yourself is scammed out of.", 9 | usage: '[username]', 10 | configRequired: ['credentials.client_id', 'credentials.client_secret'], 11 | call: obj => { 12 | return new Promise((resolve, reject) => { 13 | const { argv, msg, user_ign } = obj; 14 | const top_user = helper.getUsername(argv, msg, user_ign); 15 | 16 | osu.get_tops({ user: top_user, count: 200 }, (err, response) => { 17 | if (err) { 18 | reject(err); 19 | } 20 | 21 | const { tops, user } = response; 22 | 23 | let total = 0; 24 | 25 | for (const grade in user.statistics?.grade_counts || {}) { 26 | total += user.statistics.grade_counts[grade] ?? 0; 27 | } 28 | 29 | if (total < 1000) { 30 | reject("Player needs at least 1000 combined ranks to use this command."); 31 | } 32 | 33 | let pp = MAX_BONUS; 34 | 35 | for (const [index, top] of tops.entries()) { 36 | pp += top.pp * Math.pow(0.95, index); 37 | } 38 | 39 | const diff = Math.max(0, pp - (user.statistics?.pp || 0)); 40 | 41 | const threshold = MAX_BONUS - diff; 42 | let dupes = 0; 43 | 44 | for (let i = 1000; i > 0; i--) { 45 | const bonus = MAX_BONUS * (1 - Math.pow(0.995, i)); 46 | 47 | if (bonus <= threshold) { 48 | break; 49 | } 50 | 51 | dupes++; 52 | } 53 | 54 | resolve(`${user.username} is scammed out of ${diff == 0 ? '<1' : diff.toFixed(2)} bonus pp (${dupes < 50 ? '<50': '~' + dupes} duplicate maps in top 1000)`) 55 | }); 56 | }); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /commands/score.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: ['score', 'soloscore'], 6 | description: "Search for a score on a beatmap.", 7 | argsRequired: 1, 8 | startsWith: true, 9 | usage: ' [username or * for any user] [+mods]', 10 | example: [ 11 | { 12 | run: 'score https://osu.ppy.sh/b/75 * +HD', 13 | result: "Returns #1 score with HD on this beatmap." 14 | }, 15 | { 16 | run: "score https://osu.ppy.sh/b/75", 17 | result: "Returns your best score on this beatmap." 18 | }, 19 | { 20 | run: "score5 https://osu.ppy.sh/b/75 *", 21 | result: "Returns the #5 score on this beatmap." 22 | } 23 | ], 24 | configRequired: ["credentials.client_id", "credentials.client_secret"], 25 | call: obj => { 26 | return new Promise((resolve, reject) => { 27 | let { argv, msg, user_ign, last_beatmap } = obj; 28 | 29 | let score_user = helper.getUsername(argv, msg, user_ign); 30 | 31 | let beatmap_promise; 32 | 33 | let command = argv[0].toLowerCase().replace(/[0-9]/g, ''); 34 | 35 | let solo_score = argv[0].toLowerCase().startsWith('soloscore') 36 | 37 | if(!module.exports.command.includes(command)) 38 | return false; 39 | 40 | let index = 1; 41 | let match = argv[0].match(/\d+/); 42 | let _index = match > 0 ? match[0] : 1; 43 | 44 | if(_index >= 1 && _index <= 100) 45 | index = _index; 46 | 47 | let options = { index: index, solo_score: solo_score }; 48 | 49 | argv.forEach(function(arg){ 50 | if(arg.startsWith('+')) 51 | options.mods = arg.toUpperCase().substr(1).match(/.{1,2}/g); 52 | if(arg == '*') 53 | score_user = '*'; 54 | let b = osu.parse_beatmap_url_sync(arg, false); 55 | if(b) 56 | options.beatmap_id = b; 57 | let s = osu.parse_score_url_sync(arg, false); 58 | if(s) 59 | options.score_id = s; 60 | }); 61 | 62 | if(score_user != '*') 63 | options.user = score_user; 64 | 65 | if(!options.score_id && (!score_user || !options.beatmap_id)){ 66 | if(user_ign[msg.author.id] == undefined) 67 | reject(helper.commandHelp('ign-set')); 68 | else 69 | reject(helper.commandHelp('score')); 70 | return false; 71 | }else{ 72 | osu.get_score(options, (err, recent, strains_bar, ur_promise) => { 73 | if(err){ 74 | helper.error(err); 75 | reject(err); 76 | return false; 77 | }else{ 78 | let embed = osu.format_embed(recent); 79 | helper.updateLastBeatmap(recent, msg.channel.id, last_beatmap); 80 | 81 | if(ur_promise){ 82 | resolve({ 83 | embed: embed, 84 | files: [{attachment: strains_bar, name: 'strains_bar.png'}], 85 | edit_promise: new Promise((resolve, reject) => { 86 | ur_promise.then(recent => { 87 | embed = osu.format_embed(recent); 88 | resolve({embed}); 89 | }); 90 | })}); 91 | }else{ 92 | resolve({ 93 | embed: embed, 94 | files: [{attachment: strains_bar, name: 'strains_bar.png'}] 95 | }); 96 | } 97 | } 98 | }); 99 | } 100 | }); 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /commands/strains.js: -------------------------------------------------------------------------------- 1 | const { execFileSync } = require('child_process'); 2 | const URL = require('url'); 3 | const path = require('path'); 4 | const os = require('os'); 5 | 6 | const osu = require('../osu.js'); 7 | const helper = require('../helper.js'); 8 | const config = require('../config.json'); 9 | 10 | module.exports = { 11 | command: 'strains', 12 | description: "Show a visual strain graph of the star raiting over time on a beatmap.", 13 | usage: '[beatmap url] [+mods] [AR8] [CS6] [aim/speed]', 14 | example: [ 15 | { 16 | run: "strains", 17 | result: "Returns strain graph for the last beatmap." 18 | }, 19 | { 20 | run: "strains +HR CS5", 21 | result: "Returns strain graph with HR applied and CS set to 5 for the last beatmap." 22 | }, 23 | { 24 | run: "strains https://osu.ppy.sh/b/75 aim", 25 | result: "Returns aim strain graph for this beatmap." 26 | } 27 | ], 28 | configRequired: ['debug'], 29 | call: obj => { 30 | return new Promise((resolve, reject) => { 31 | let { argv, msg, last_beatmap } = obj; 32 | 33 | let beatmap_id, beatmap_url, beatmap_promise, download_promise, mods = [], ar = 2, cs, custom_url = false, type; 34 | 35 | argv.map(arg => arg.toLowerCase()); 36 | 37 | argv.slice(1).forEach(arg => { 38 | if(arg.startsWith('+')){ 39 | mods = arg.toUpperCase().substr(1).match(/.{1,2}/g); 40 | }else if(arg.startsWith('ar')){ 41 | ar = parseFloat(arg.substr(2)); 42 | }else if(arg.startsWith('cs')){ 43 | cs = parseFloat(arg.substr(2)); 44 | }else if(arg.toLowerCase() == 'aim'){ 45 | type = 'aim'; 46 | }else if(arg.toLowerCase() == 'speed'){ 47 | type = 'speed'; 48 | }else if(arg.toLowerCase() == 'flashlight'){ 49 | type = 'flashlight'; 50 | }else{ 51 | beatmap_url = arg; 52 | beatmap_promise = osu.parse_beatmap_url(beatmap_url); 53 | beatmap_promise.then(response => { 54 | beatmap_id = response; 55 | if(!beatmap_id) custom_url = true; 56 | }); 57 | } 58 | }); 59 | 60 | Promise.resolve(beatmap_promise).finally(() => { 61 | if(!(msg.channel.id in last_beatmap)){ 62 | reject(helper.commandHelp('strains')) 63 | return false; 64 | }else if(!beatmap_id && !custom_url){ 65 | beatmap_id = last_beatmap[msg.channel.id].beatmap_id; 66 | download_promise = helper.downloadBeatmap(beatmap_id).catch(helper.error); 67 | if (!mods) { 68 | mods = last_beatmap[msg.channel.id].mods 69 | } 70 | if (Array.isArray(mods)) { 71 | if (typeof mods[0] === 'object') { 72 | mods = mods.map(mod => mod.acronym) 73 | } else if (typeof mods[0] === 'string') { 74 | mods = mods 75 | } else { 76 | mods = [] 77 | } 78 | } else { 79 | mods = [] 80 | } 81 | } 82 | 83 | let download_path = path.resolve(config.osu_cache_path, `${beatmap_id}.osu`); 84 | 85 | if(!beatmap_id || custom_url){ 86 | let download_url = URL.parse(beatmap_url); 87 | download_path = path.resolve(os.tmpdir(), `${Math.floor(Math.random() * 1000000) + 1}.osu`); 88 | 89 | download_promise = helper.downloadFile(download_path, download_url); 90 | 91 | download_promise.catch(reject); 92 | } 93 | 94 | Promise.resolve(download_promise).then(() => { 95 | osu.get_strains_graph(download_path, mods.join(''), cs, ar, type).then(buf => { 96 | if(beatmap_id){ 97 | helper.updateLastBeatmap({ 98 | beatmap_id, 99 | mods, 100 | fail_percent: last_beatmap[msg.channel.id].fail_percent || 1, 101 | acc: last_beatmap[msg.channel.id].acc || 100 102 | }, msg.channel.id, last_beatmap); 103 | } 104 | 105 | resolve({files: [{ attachment: buf, name: 'strains.png' }]}); 106 | }).catch(err => { 107 | reject(err); 108 | }); 109 | }); 110 | }); 111 | }); 112 | } 113 | }; 114 | -------------------------------------------------------------------------------- /commands/streamin.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | command: 'streamin', 3 | description: 'he smells', 4 | call: () => { 5 | return "stinks"; 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /commands/tap.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: 'tap', 6 | description: "Calculate BPM values for different beat snap divisors", 7 | usage: ' ', 8 | example: [ 9 | { 10 | run: "tap 200 1/4", 11 | result: "Return equivalent tapping values for 200 BPM at 1/4" 12 | }, 13 | { 14 | run: "tap 150 1/3", 15 | result: "Return equivalent tapping values for 150 BPM at 1/3" 16 | } 17 | ], 18 | argsRequired: 2, 19 | call: obj => { 20 | return new Promise((resolve, reject) => { 21 | let { argv } = obj; 22 | 23 | let bpm = Number(argv[1]); 24 | let divisors = ["1/2", "1/3", "1/4", "1/6", "1/8"]; 25 | 26 | let divisor = argv[2].trim(); 27 | 28 | if(isNaN(bpm)) 29 | reject("BPM is not a number"); 30 | 31 | if(!divisors.includes(divisor)) 32 | reject("Not a valid beat snap divisor"); 33 | 34 | let divisor_parts = divisor.split("/"); 35 | 36 | let bpm_raw = bpm / (1 / Number(divisor_parts[1])); 37 | 38 | let embed = { 39 | title: "BPM Calculator", 40 | description: "Usually: 1/2 = Jumps, 1/3 = Alt, 1/4 = Streams", 41 | fields: [] 42 | }; 43 | 44 | const multipliers = [1, 1.5, 0.75]; 45 | 46 | for(let i = 0; i <= 2; i++){ 47 | let name = ""; 48 | let value = ""; 49 | 50 | divisors.forEach((div, index) => { 51 | let bpm_calculated = bpm_raw * (1 / Number(div.split("/")[1])) * multipliers[i]; 52 | 53 | if(div == divisor){ 54 | value += '**'; 55 | name += '**'; 56 | } 57 | 58 | value += Math.round(bpm_calculated); 59 | name += div; 60 | 61 | if(div == divisor){ 62 | value += '**'; 63 | name += '**'; 64 | } 65 | 66 | if(index < divisors.length){ 67 | name += '   '; 68 | 69 | if(bpm_calculated < 100) 70 | value += '    '; 71 | else if(bpm_calculated < 1000) 72 | value += '  '; 73 | else 74 | value += ' '; 75 | } 76 | }); 77 | 78 | if(i == 0) 79 | name += 'NOMOD'; 80 | 81 | if(i == 1) 82 | name += '+DT'; 83 | 84 | if(i == 2) 85 | name += '+HT'; 86 | 87 | embed.fields.push({ 88 | name, value 89 | }); 90 | } 91 | 92 | resolve({embed}); 93 | }); 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /commands/time.js: -------------------------------------------------------------------------------- 1 | const tzlookup = require('tz-lookup'); 2 | const axios = require('axios'); 3 | 4 | const { DateTime, IANAZone } = require('luxon'); 5 | const helper = require('../helper'); 6 | 7 | const Nominatim = axios.create({ 8 | baseURL: 'https://nominatim.openstreetmap.org/', 9 | params: { 10 | format: 'json' 11 | } 12 | }); 13 | 14 | module.exports = { 15 | command: 'time', 16 | description: "Get the current time at a place.", 17 | usage: '[name of place, e.g. city]', 18 | example: [ 19 | { 20 | run: "time london", 21 | result: "Returns the current time in London." 22 | } 23 | ], 24 | call: obj => { 25 | return new Promise((resolve, reject) => { 26 | let { argv } = obj; 27 | let zoneName = 'utc'; 28 | 29 | if(argv.length == 1) 30 | resolve(`${DateTime.now().toUTC().toFormat('HH:mm, MMM dd')} (UTC)`); 31 | 32 | let q = argv.slice(1).join(" "); 33 | 34 | Nominatim.get('search', { params: { q } }).then(response => { 35 | if(response.data.length > 0){ 36 | let place = response.data.sort((a, b) => b.importance - a.importance)[0]; 37 | let timezone = tzlookup(Number(place.lat), Number(place.lon)); 38 | 39 | const zone = IANAZone.create(timezone); 40 | 41 | if(zone.isValid) 42 | zoneName = timezone; 43 | 44 | resolve(`${DateTime.now().setZone(zoneName).toFormat('HH:mm, MMM dd')} (${timezone})`); 45 | }else{ 46 | reject("Couldn't find this place"); 47 | } 48 | }).catch(err => { 49 | reject("An error occured fetching the place"); 50 | }); 51 | }); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /commands/top.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | const config = require('../config.json'); 4 | 5 | module.exports = { 6 | command: ['top', 'rb', 'recentbest', 'ob', 'oldbest'], 7 | description: "Show a specific top play.", 8 | startsWith: true, 9 | usage: '[username]', 10 | example: [ 11 | { 12 | run: "top", 13 | result: "Returns your #1 top play." 14 | }, 15 | { 16 | run: "top5 vaxei", 17 | result: "Returns Vaxei's #5 top play." 18 | }, 19 | { 20 | run: "rb", 21 | result: "Returns your most recent top play." 22 | }, 23 | { 24 | run: "ob", 25 | result: "Returns your oldest top play (from your top 100)." 26 | } 27 | ], 28 | configRequired: ["credentials.client_id", "credentials.client_secret"], 29 | call: obj => { 30 | return new Promise((resolve, reject) => { 31 | let { argv, msg, user_ign, last_beatmap } = obj; 32 | 33 | let top_user = helper.getUsername(argv, msg, user_ign); 34 | 35 | let command = argv[0].toLowerCase().replace(/[0-9]/g, ''); 36 | 37 | if(!module.exports.command.includes(command)) 38 | return false; 39 | 40 | let rb = argv[0].toLowerCase().startsWith('rb') || argv[0].toLowerCase().startsWith('recentbest'); 41 | let ob = argv[0].toLowerCase().startsWith('ob') || argv[0].toLowerCase().startsWith('oldbest'); 42 | 43 | let index = 1; 44 | let match = argv[0].match(/\d+/); 45 | let _index = match > 0 ? match[0] : 1; 46 | 47 | if(_index >= 1 && _index <= 200) 48 | index = _index; 49 | 50 | if(!top_user){ 51 | if(user_ign[msg.author.id] == undefined){ 52 | reject(helper.commandHelp('ign-set')); 53 | }else{ 54 | reject(helper.commandHelp('top')); 55 | } 56 | 57 | return false; 58 | }else{ 59 | osu.get_top({user: top_user, index: index, rb: rb, ob: ob}, (err, recent, strains_bar, ur_promise) => { 60 | if(err){ 61 | helper.error(err); 62 | reject(err); 63 | return false; 64 | }else{ 65 | let embed = osu.format_embed(recent); 66 | helper.updateLastBeatmap(recent, msg.channel.id, last_beatmap); 67 | 68 | if(ur_promise){ 69 | resolve({ 70 | embed: embed, 71 | files: [{attachment: strains_bar, name: 'strains_bar.png'}], 72 | edit_promise: new Promise((resolve, reject) => { 73 | ur_promise.then(recent => { 74 | embed = osu.format_embed(recent); 75 | resolve({embed}); 76 | }); 77 | })}); 78 | }else{ 79 | resolve({ 80 | embed: embed, 81 | files: [{attachment: strains_bar, name: 'strains_bar.png'}] 82 | }); 83 | } 84 | } 85 | }); 86 | } 87 | }); 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /commands/tops.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | const { DateTime } = require('luxon'); 4 | const config = require('../config.json'); 5 | 6 | module.exports = { 7 | command: 'tops', 8 | description: "Show a list of top plays", 9 | startsWith: true, 10 | usage: '[username]', 11 | example: [ 12 | { 13 | run: "tops", 14 | result: "Returns your top 5 plays." 15 | }, 16 | { 17 | run: "tops7 vaxei", 18 | result: "Returns Vaxei's top 7 plays." 19 | } 20 | ], 21 | configRequired: ["credentials.client_id", "credentials.client_secret"], 22 | call: obj => { 23 | return new Promise((resolve, reject) => { 24 | let { argv, msg, user_ign, last_beatmap } = obj; 25 | 26 | let top_user = helper.getUsername(argv, msg, user_ign); 27 | 28 | let count = 5; 29 | let match = argv[0].match(/\d+/); 30 | 31 | if(match != null && !isNaN(match[0])) 32 | count = Math.max(1, Math.min(match[0], 25)); 33 | 34 | if(!top_user){ 35 | if(user_ign[msg.author.id] == undefined){ 36 | reject(helper.commandHelp('ign-set')); 37 | }else{ 38 | reject(helper.commandHelp('top')); 39 | } 40 | 41 | return false; 42 | }else{ 43 | osu.get_tops({user: top_user, count},(err, response) => { 44 | if(err){ 45 | helper.error(err); 46 | reject(err); 47 | }else{ 48 | const { tops, user } = response; 49 | 50 | let embed = {fields: []}; 51 | embed.color = 12277111; 52 | embed.author = { 53 | url: `https://osu.ppy.sh/u/${user.id}`, 54 | name: `${user.username} – ${Number(user.statistics.pp).toFixed(2)}pp (#${Number(user.statistics.global_rank).toLocaleString()})`, 55 | icon_url: user.avatar_url 56 | }; 57 | 58 | embed.thumbnail = { 59 | url: `https://b.ppy.sh/thumb/${tops[0].beatmap.beatmapset_id}l.jpg` 60 | }; 61 | 62 | embed.fields = []; 63 | 64 | for(const top of tops){ 65 | let name = `${top.rank_emoji} ${top.stars.toFixed(2)}★ ${top.beatmapset.artist} - ${top.beatmapset.title} [${top.beatmap.version}]`; 66 | 67 | if(top.mods.length > 0) 68 | name += ` +${osu.sanitize_mods(top.mods).join(",")}`; 69 | 70 | name += ` ${top.accuracy}%`; 71 | 72 | let value = `[🔗](https://osu.ppy.sh/b/${top.beatmap.id}) `; 73 | 74 | if(Number(top.max_combo) < top.beatmap.max_combo && top.pp_fc > top.pp) 75 | value += `**${Number(top.pp).toFixed(2)}pp** ➔ ${top.pp_fc.toFixed(2)}pp for ${top.acc_fc}% FC${helper.sep}`; 76 | else 77 | value += `**${Number(top.pp).toFixed(2)}pp**${helper.sep}` 78 | 79 | if(Number(top.max_combo) < top.beatmap.max_combo) 80 | value += `${top.max_combo}/${top.beatmap.max_combo}x`; 81 | else 82 | value += `${top.max_combo}x`; 83 | 84 | if(Number(top.statistics.ok ?? 0) > 0 || Number(top.statistics.meh ?? 0) > 0 || Number(top.statistics.miss ?? 0) > 0) 85 | value += helper.sep; 86 | 87 | if(Number(top.statistics.ok ?? 0) > 0) 88 | value += `${top.statistics.ok}x100`; 89 | 90 | if(Number(top.statistics.meh ?? 0) > 0){ 91 | if(Number(top.statistics.ok ?? 0) > 0) value += helper.sep; 92 | value += `${top.statistics.meh ?? 0}x50`; 93 | } 94 | 95 | if(Number(top.statistics.miss ?? 0) > 0){ 96 | if(Number(top.statistics.ok ?? 0) > 0 || Number(top.statistics.meh ?? 0) > 0) value += helper.sep; 97 | value += `${top.statistics.miss ?? 0}xMiss`; 98 | } 99 | 100 | value += `${helper.sep}` 101 | 102 | embed.fields.push({ name, value }) 103 | } 104 | 105 | resolve({ embed }); 106 | } 107 | }) 108 | } 109 | }) 110 | } 111 | }; 112 | -------------------------------------------------------------------------------- /commands/urban.js: -------------------------------------------------------------------------------- 1 | const ud = require('@dmzoneill/urban-dictionary') 2 | 3 | module.exports = { 4 | command: 'urban', 5 | description: "Shows the definition of a word on urbandictionary.", 6 | argsRequired: 1, 7 | usage: '', 8 | example: { 9 | run: "urban help", 10 | result: "Returns the definition for the word 'help'." 11 | }, 12 | call: obj => { 13 | return new Promise((resolve, reject) => { 14 | let { argv } = obj; 15 | 16 | let word = argv.slice(1).join(" "); 17 | 18 | ud.define(word).then((results) => { 19 | var definition = results[0].definition; 20 | var example = results[0].example; 21 | 22 | resolve({ 23 | embed: { 24 | description: definition.replace(/\[|\]/g, ''), 25 | color: 12277111, 26 | author: { 27 | name: results[0].word, 28 | url: results[0].permalink 29 | }, 30 | fields: {name: 'Example', value: example.replace(/\[|\]/g, '')}, 31 | timestamp: new Date(results[0].written_on), 32 | footer: {text: 'by ' + results[0].author} 33 | } 34 | }); 35 | }).catch((error) => { 36 | reject(error.message); 37 | }) 38 | }); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /commands/viewers.js: -------------------------------------------------------------------------------- 1 | const config = require('../config.json'); 2 | 3 | const { Duration } = require('luxon'); 4 | const { AppTokenAuthProvider } = require('@twurple/auth'); 5 | const { ApiClient } = require('@twurple/api'); 6 | 7 | const authProvider = new AppTokenAuthProvider( 8 | config.credentials.twitch_client_id, 9 | config.credentials.twitch_client_secret 10 | ); 11 | 12 | const apiClient = new ApiClient({ authProvider }); 13 | 14 | module.exports = { 15 | command: ['uptime', 'downtime', 'viewers'], 16 | description: "See how many people are watching a Twitch channel.", 17 | argsRequired: 1, 18 | usage: '', 19 | example: { 20 | run: "viewers distortion2", 21 | result: "Returns how many viewers distortion2 currently has (if they're live)." 22 | }, 23 | configRequired: ['credentials.twitch_client_id', 'credentials.twitch_client_secret'], 24 | call: async obj => { 25 | let { argv } = obj; 26 | 27 | let channel_name = argv[1]; 28 | 29 | const user = await apiClient.users.getUserByName(channel_name); 30 | 31 | if (!user) { 32 | throw "Twitch User not found."; 33 | } 34 | 35 | const { displayName: name } = user; 36 | const channelUrl = `https://twitch.tv/${user.name}`; 37 | 38 | const embed = { 39 | color: 6570404, 40 | author: { 41 | icon_url: user.profilePictureUrl, 42 | url: channelUrl, 43 | name 44 | }, 45 | url: channelUrl 46 | }; 47 | 48 | const stream = await apiClient.streams.getStreamByUserName(user); 49 | 50 | if (!stream) { 51 | const { data: videos } = await apiClient.videos.getVideosByUser(user, { type: 'archive' }); 52 | 53 | if (videos.length == 0) { 54 | embed.description = "Currently offline."; 55 | return { embed }; 56 | } 57 | 58 | const [lastStream] = videos; 59 | const { creationDate } = lastStream; 60 | 61 | const lastStreamed = Math.floor(creationDate.getTime() / 1000); 62 | 63 | embed.description = `Last streamed .`; 64 | return { embed }; 65 | } 66 | 67 | embed.title = stream.title; 68 | 69 | embed.fields = []; 70 | 71 | if (stream.gameName) { 72 | embed.fields.push({ 73 | name: 'Game', 74 | value: stream.gameName, 75 | inline: true 76 | }); 77 | } 78 | 79 | embed.fields.push({ 80 | name: 'Viewers', 81 | value: stream.viewers.toLocaleString(), 82 | inline: true 83 | }); 84 | 85 | const uptime = Date.now() - stream.startDate.getTime(); 86 | const duration = Duration.fromMillis(uptime); 87 | 88 | const uptimeText = 89 | uptime > 60 * 60 * 1000 90 | ? duration.toFormat("h'h' m'm'") 91 | : duration.toFormat("m'm'"); 92 | 93 | console.log(JSON.stringify(stream)); 94 | 95 | embed.fields.push({ 96 | name: 'Uptime', 97 | value: uptimeText, 98 | inline: true 99 | }); 100 | 101 | return { embed }; 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /commands/w.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | command: 'w;', 3 | call: () => { 4 | return ";w;"; 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /commands/weather.js: -------------------------------------------------------------------------------- 1 | const helper = require('../helper.js'); 2 | const config = require('../config.json'); 3 | const weather = require('openweather-apis'); 4 | 5 | weather.setLang('en'); 6 | weather.setUnits('metric'); 7 | weather.setAPPID(config.credentials.open_weather_map_api); 8 | 9 | module.exports = { 10 | command: 'weather', 11 | description: "Send the current weather for the given city.", 12 | argsRequired: 1, 13 | usage: '', 14 | example: { 15 | run: "weather London", 16 | result: "Returns the current weather in London." 17 | }, 18 | configRequired: ['credentials.open_weather_map_api'], 19 | call: obj => { 20 | return new Promise((resolve, reject) => { 21 | let { argv, msg } = obj; 22 | const author = msg.author; 23 | const avatar_url = `https://cdn.discordapp.com/avatars/${author.id}/${author.avatar}.png?size=256` 24 | 25 | argv.shift() 26 | const city = argv.join(" "); 27 | 28 | weather.setCity("") 29 | weather.setCityId("") 30 | 31 | if (isNaN(city)) { 32 | weather.setCity(city); 33 | } else { 34 | weather.setCityId(city); 35 | } 36 | weather.getAllWeather(function(err, JSONObj){ 37 | if (err) { 38 | helper.error(err); 39 | reject("Either the OpenWeatherMap API is down or you provided an invalid location."); 40 | } 41 | 42 | let city_name = JSONObj.name 43 | let humidity = JSONObj.main.humidity 44 | let weather_description = JSONObj.weather[0].description 45 | 46 | let celsius = JSONObj.main.temp 47 | let fahrenheit = celsius * 9 / 5 + 32 48 | 49 | let date = new Date() 50 | date.setSeconds(date.getSeconds() + JSONObj.timezone) 51 | 52 | let date_string = `${date.toLocaleString('en-GB', { timeZone: 'UTC', timeStyle: 'short' })}, ${date.toLocaleString("en-US", { weekday: "long", timeZone: 'UTC'})}` 53 | let icon_url = `https://openweathermap.org/img/wn/${JSONObj.weather[0].icon}@2x.png` 54 | 55 | let direction_val = parseInt((JSONObj.wind.deg / 22.5) + .5) 56 | let directions = ["north", "north-northeast", "northeast", "east-northeast", "east", "east-southeast", 57 | "southeast", "south-southeast", "south", "south-southwest", "southwest", "west-southwest", "west", "west-northwest", "northwest", "north-northwest"] 58 | let wind_direction = directions[(direction_val % 16)] 59 | let wind_speed = JSONObj.wind.speed * 3.6 60 | 61 | let flag_emoji = `:flag_${JSONObj.sys.country.toLowerCase()}:` 62 | 63 | resolve({ 64 | embed: { 65 | color: 12277111, 66 | author: { 67 | name: `${author.username}#${author.discriminator}`, 68 | icon_url: avatar_url 69 | }, 70 | thumbnail: { 71 | url: icon_url 72 | }, 73 | title: `Weather in **${city_name}** at **${date_string}** ${flag_emoji}`, 74 | fields: [ 75 | { 76 | name: "Current Conditions:", 77 | value: `**${weather_description}** at **${celsius.toFixed(1)}°C** / **${fahrenheit.toFixed(1)}°F**` 78 | }, 79 | { 80 | name: "Humidity", 81 | value: `${humidity}%`, 82 | inline: true 83 | }, 84 | { 85 | name: "Wind", 86 | value: `${wind_speed.toFixed(1)} km/h from the ${wind_direction}`, 87 | inline: true 88 | }, 89 | 90 | ], 91 | footer: { 92 | text: "Data provided by OpenWeatherMap", 93 | icon_url: "http://f.gendo.moe/KlhvQJoD.png" 94 | } 95 | } 96 | }) 97 | }); 98 | }); 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /commands/with.js: -------------------------------------------------------------------------------- 1 | const osu = require('../osu.js'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | command: ['with', 'map'], 6 | description: "Show pp values of a beatmap with several accuracies or a specified accuracy.", 7 | usage: '[beatmap url] [+mods] [98.34%]', 8 | example: [ 9 | { 10 | run: "with", 11 | result: "Returns pp values for the last beatmap with the same mods." 12 | }, 13 | { 14 | run: "with +", 15 | result: "Returns pp values for the last beatmap without mods." 16 | }, 17 | { 18 | run: "with +HD 97.5%", 19 | result: "Returns pp value for the last beatmap with 97.5% accuracy and HD applied." 20 | } 21 | ], 22 | configRequired: ["credentials.client_id", "credentials.client_secret"], 23 | call: obj => { 24 | return new Promise((resolve, reject) => { 25 | let { argv, msg, user_ign, last_beatmap } = obj; 26 | 27 | let modsSet = false, accSet = false, beatmapSet = false, speedSet = false; 28 | 29 | let options = { 30 | mods: [], 31 | custom_acc: 100 32 | }; 33 | 34 | argv.slice(1).forEach(arg => { 35 | if(arg.startsWith('+')){ 36 | options.mods = arg.toUpperCase().substr(1).match(/.{1,2}/g).map(mod => ({ "acronym": mod })); 37 | modsSet = true; 38 | }else if(arg.endsWith('%')){ 39 | options.custom_acc = parseFloat(arg); 40 | accSet = true; 41 | }else if(arg.endsWith('*') || arg.endsWith('x')) { 42 | options.speed_change = parseFloat(arg); 43 | speedSet = true; 44 | }else{ 45 | options.beatmap_id = osu.parse_beatmap_url_sync(arg, false); 46 | if (options.beatmap_id) beatmapSet = true; 47 | } 48 | }); 49 | 50 | if(msg.channel.id in last_beatmap && beatmapSet == false){ 51 | options.beatmap_id = last_beatmap[msg.channel.id].beatmap_id; 52 | 53 | if(!modsSet && !speedSet) { 54 | mods = last_beatmap[msg.channel.id].mods 55 | if (Array.isArray(mods)) { 56 | options.mods = mods 57 | } else { 58 | options.mods = [] 59 | } 60 | } 61 | 62 | if(!accSet) 63 | options.custom_acc = last_beatmap[msg.channel.id].acc; 64 | } 65 | 66 | if(!(msg.channel.id in last_beatmap) && options.beatmap_id == null){ 67 | reject('No recent score to get the beatmap from'); 68 | return false; 69 | } 70 | 71 | osu.get_pp(options, (err, embed) => { 72 | if(err){ 73 | helper.error(err); 74 | reject(err); 75 | }else{ 76 | options.acc = options.custom_acc; 77 | options.fail_percent = 1; 78 | helper.updateLastBeatmap(options, msg.channel.id, last_beatmap); 79 | resolve({embed: embed}); 80 | } 81 | }); 82 | }); 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /emotes/A_Rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/emotes/A_Rank.png -------------------------------------------------------------------------------- /emotes/B_Rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/emotes/B_Rank.png -------------------------------------------------------------------------------- /emotes/C_Rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/emotes/C_Rank.png -------------------------------------------------------------------------------- /emotes/D_Rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/emotes/D_Rank.png -------------------------------------------------------------------------------- /emotes/F_Rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/emotes/F_Rank.png -------------------------------------------------------------------------------- /emotes/SH_Rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/emotes/SH_Rank.png -------------------------------------------------------------------------------- /emotes/S_Rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/emotes/S_Rank.png -------------------------------------------------------------------------------- /emotes/XH_Rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/emotes/XH_Rank.png -------------------------------------------------------------------------------- /emotes/X_Rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/emotes/X_Rank.png -------------------------------------------------------------------------------- /fantasynamegen.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const helper = require('./helper.js'); 4 | 5 | const fantasyNameGen = axios.create({ 6 | baseURL: 'https://www.fantasynamegen.com', 7 | responseType: 'document' 8 | }) 9 | 10 | const fantasy_types = ["human", "elf", "dwarf", "hobbit", "barbarian", "orc", "evil", "asian", "arabic", 11 | "surname", "sci-fi", "lovecraft", "reptilian", "aztec", "ratman", "demon", "dragon", "wizard", "mixed", 12 | "english", "place", "title", "military", "hero/villain", "rockband"]; 13 | 14 | const fantasy_types_raw = {"surname": "surnames", "sci-fi": "sf", "english": "enames", "place": "places", 15 | "title": "titles", "military": "operation", "hero/villain": "super"}; 16 | 17 | const fantasy_length = ["short", "medium", "long"]; 18 | 19 | module.exports = { 20 | fantasyTypes: fantasy_types, 21 | fantasyLengths: fantasy_length, 22 | getFantasyName: (type, length, author) => { 23 | return new Promise((resolve, reject) => { 24 | if(!fantasy_types.includes(type)){ 25 | reject('Unknown type'); 26 | return false; 27 | } 28 | 29 | if(!fantasy_length.includes(length)){ 30 | reject('Unknown length'); 31 | return false; 32 | } 33 | 34 | if(type in fantasy_types_raw) 35 | type = fantasy_types_raw[type]; 36 | 37 | fantasyNameGen.get(`/${type}/${length}/`).then(response => { 38 | try{ 39 | let names = response.data.split("
    ").pop().split("
")[0].split("\n"); 40 | 41 | names.splice(0, 1); 42 | names.splice(names.length - 1, 1); 43 | 44 | let name = names[Math.floor(Math.random() * names.length)].split("
  • ").pop().split("
  • ")[0]; 45 | 46 | if(name.includes("<name> ")) 47 | name = name.replace("<name> ", author); 48 | 49 | if(type == 'surnames') 50 | name = author + " " + name; 51 | 52 | resolve(name); 53 | }catch(err){ 54 | helper.error(err); 55 | reject("Error parsing site"); 56 | } 57 | }).catch(err => { 58 | helper.error(err); 59 | reject("Couldn't proccess request"); 60 | 61 | }); 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /generate-commands-md.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const config = require('./config.json'); 4 | 5 | let commands = []; 6 | let commands_path = path.resolve(__dirname, 'commands'); 7 | 8 | let output = `# Commands 9 | ### Table of contents`; 10 | 11 | fs.readdir(commands_path, (err, items) => { 12 | if(err) 13 | throw "Unable to read commands folder"; 14 | 15 | items.forEach(item => { 16 | if(path.extname(item) == '.js') 17 | commands.push(require(path.resolve(commands_path, item))); 18 | }); 19 | 20 | commands.forEach(command => { 21 | if(!Array.isArray(command.command)) 22 | command.command = [command.command]; 23 | }); 24 | 25 | commands.forEach(command => { 26 | output += `\n- [${config.prefix}${command.command[0]}](#${command.command[0]})`; 27 | }); 28 | 29 | output += `\n---` 30 | 31 | commands.forEach(command => { 32 | output += `\n## ${config.prefix}${command.command[0]}`; 33 | 34 | if(command.description){ 35 | if(!Array.isArray(command.description)) 36 | command.description = [command.description]; 37 | 38 | output += `\n${command.description.join("\n")}`; 39 | } 40 | 41 | if(command.command.length > 1) 42 | output += `\n\n**Variations**: \`${config.prefix}${command.command.join('`, `' + config.prefix)}\``; 43 | 44 | output += `\n\n**Usage**: \`${config.prefix}${command.command[0]}`; 45 | 46 | if(command.usage) 47 | output += ` ${command.usage}`; 48 | 49 | output += '`'; 50 | 51 | if(command.example){ 52 | if(!Array.isArray(command.example)) 53 | command.example = [command.example]; 54 | 55 | output += `\n### Example${command.example.length > 1 ? 's' : ''}:`; 56 | 57 | command.example.forEach(example => { 58 | output += `\n\n\`\`\`\n${config.prefix}${example.run}\n\`\`\``; 59 | if(example.result) 60 | output += `\n${example.result}`; 61 | }); 62 | } 63 | 64 | }); 65 | 66 | fs.writeFileSync('COMMANDS.md', output); 67 | process.exit(0); 68 | }); 69 | -------------------------------------------------------------------------------- /handlers/updatelastmessage.js: -------------------------------------------------------------------------------- 1 | const config = require('../config.json'); 2 | const helper = require('../helper.js'); 3 | 4 | module.exports = { 5 | message: obj => { 6 | let { msg, last_message, client } = obj; 7 | 8 | if(!msg.content.startsWith(config.prefix) && msg.author.id != client.user.id){ 9 | last_message[msg.channel.id] = msg.content; 10 | helper.setItem('last_message', JSON.stringify(last_message)); 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /helper.js: -------------------------------------------------------------------------------- 1 | const LocalStorage = require('node-localstorage').LocalStorage; 2 | localStorage = new LocalStorage('./scratch'); 3 | 4 | const { DateTime } = require('luxon'); 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const { execFileSync } = require('child_process'); 8 | const axios = require('axios'); 9 | const fileExists = async path => !!(await fs.promises.stat(path).catch(e => false)); 10 | 11 | const config = require('./config.json'); 12 | 13 | const sep = ' ✦ '; 14 | const cmd_escape = "```"; 15 | 16 | let commands; 17 | 18 | module.exports = { 19 | fileExists, 20 | 21 | init: _commands => { 22 | commands = _commands; 23 | }, 24 | 25 | sep: sep, 26 | 27 | cmd_escape: cmd_escape, 28 | 29 | log: (...params) => { 30 | console.log(`[${DateTime.now().toString()}]`, ...params); 31 | }, 32 | 33 | error: (...params) => { 34 | console.error(`[${DateTime.now().toString()}]`, ...params); 35 | }, 36 | 37 | setItem: (item, data) => { 38 | localStorage.setItem(item, data); 39 | }, 40 | 41 | getItem: item => { 42 | return localStorage.getItem(item); 43 | }, 44 | 45 | commandHelp: command_name => { 46 | if(Array.isArray(command_name)) 47 | command_name = command_name[0]; 48 | 49 | for(let i = 0; i < commands.length; i++){ 50 | let command = commands[i]; 51 | 52 | if(!Array.isArray(command.command)) 53 | command.command = [command.command]; 54 | 55 | if(command.command.includes(command_name)){ 56 | let embed = { 57 | fields: [] 58 | }; 59 | 60 | let commands_value = ""; 61 | let commands_name = "Command"; 62 | 63 | if(command.command.length > 1) 64 | commands_name += "s"; 65 | 66 | command.command.forEach((_command, index) => { 67 | if(index > 0) 68 | commands_value += ", "; 69 | 70 | commands_value += `\`${config.prefix}${_command}\``; 71 | }); 72 | 73 | embed.fields.push({ 74 | name: commands_name, 75 | value: commands_value + "\n" 76 | }); 77 | 78 | if(!Array.isArray(command.description)) 79 | command.description = [command.description]; 80 | 81 | if(command.description){ 82 | embed.fields.push({ 83 | name: "Description", 84 | value: command.description.join("\n") + "\n" 85 | }) 86 | } 87 | 88 | if(command.usage){ 89 | embed.fields.push({ 90 | name: "Usage", 91 | value: `${cmd_escape}${config.prefix}${command.command[0]} ${command.usage}${cmd_escape}\n` 92 | }); 93 | } 94 | 95 | if(command.example){ 96 | let examples = command.example; 97 | let examples_value = ""; 98 | let examples_name = "Example"; 99 | 100 | if(!Array.isArray(examples)) 101 | examples = [examples]; 102 | 103 | if(examples.length > 1) 104 | examples_name += "s"; 105 | 106 | examples.forEach((example, index) => { 107 | if(index > 0) 108 | examples_value += "\n\n"; 109 | 110 | if(typeof example === 'object'){ 111 | examples_value += `${cmd_escape}${config.prefix}${example.run}${cmd_escape}`; 112 | examples_value += example.result; 113 | }else{ 114 | examples_value += `${cmd_escape}${config.prefix}${example}${cmd_escape}`; 115 | } 116 | }); 117 | 118 | embed.fields.push({ 119 | name: examples_name, 120 | value: examples_value + "\n" 121 | }) 122 | } 123 | 124 | return {embed: embed}; 125 | } 126 | } 127 | 128 | return "Couldn't find command."; 129 | }, 130 | 131 | validateBeatmap: async beatmap_path => { 132 | let file = await fs.promises.readFile(beatmap_path, 'utf8'); 133 | let lines = file.split("\n"); 134 | 135 | if(lines.length > 0 && !lines[0].includes('osu file format')) 136 | return false; 137 | 138 | return true; 139 | }, 140 | 141 | downloadFile: (file_path, url) => { 142 | return new Promise((resolve, reject) => { 143 | fs.promises.mkdir(path.dirname(file_path), { recursive: true }).then(() => { 144 | axios.get(url, {responseType: 'stream'}).then(response => { 145 | const stream = response.data.pipe(fs.createWriteStream(file_path)); 146 | 147 | stream.on('finish', async () => { 148 | if(await module.exports.validateBeatmap(file_path) == false){ 149 | await fs.promises.rm(file_path, { recursive: true }); 150 | 151 | reject('Failed downloading beatmap'); 152 | } 153 | 154 | resolve(); 155 | }); 156 | 157 | stream.on('error', err => { 158 | reject(err); 159 | }); 160 | }).catch(reject); 161 | }).catch(reject); 162 | }); 163 | }, 164 | 165 | downloadBeatmap: async beatmap_id => { 166 | try{ 167 | let beatmap_path = path.resolve(config.osu_cache_path, `${beatmap_id}.osu`); 168 | 169 | await fs.promises.mkdir(path.dirname(beatmap_path), { recursive: true }); 170 | 171 | if(await fileExists(beatmap_path) == false 172 | || (await fileExists(beatmap_path) && await module.exports.validateBeatmap(beatmap_path) == false)){ 173 | await module.exports.downloadFile(beatmap_path, `https://osu.ppy.sh/osu/${beatmap_id}`); 174 | } 175 | }catch(err){ 176 | module.exports.error(err); 177 | 178 | throw "Couldn't download beatmap"; 179 | } 180 | }, 181 | 182 | emote: (emoteName, guild, client) => { 183 | let emote; 184 | 185 | if(guild) 186 | emote = guild.emojis.cache.find(emoji => emoji.name.toLowerCase() === emoteName.toLowerCase()); 187 | 188 | if(!emote) 189 | emote = client.emojis.cache.find(emoji => emoji.name.toLowerCase() === emoteName.toLowerCase()); 190 | 191 | return emote; 192 | }, 193 | 194 | replaceAll: (target, search, replacement) => { 195 | return target.split(search).join(replacement); 196 | }, 197 | 198 | splitWithTail: (string, delimiter, count) => { 199 | let parts = string.split(delimiter); 200 | let tail = parts.slice(count).join(delimiter); 201 | let result = parts.slice(0,count); 202 | result.push(tail); 203 | 204 | return result; 205 | }, 206 | 207 | getRandomArbitrary: (min, max) => { 208 | return Math.random() * (max - min) + min; 209 | }, 210 | 211 | getRandomInt: (min, max) => { 212 | min = Math.ceil(min); 213 | max = Math.floor(max); 214 | return Math.floor(Math.random() * (max - min)) + min; 215 | }, 216 | 217 | simplifyUsername: username => { 218 | return username.replace(/[^a-zA-Z0-9]/g, '').toLowerCase().trim(); 219 | }, 220 | 221 | validUsername: username => { 222 | return !(/[^a-zA-Z0-9\_\[\]\ \-]/g.test(username)); 223 | }, 224 | 225 | getUsername: (args, message, user_ign) => { 226 | let return_username; 227 | 228 | args = args.slice(1); 229 | 230 | args.forEach(function(arg){ 231 | if(module.exports.validUsername(arg)) 232 | return_username = arg; 233 | else if(module.exports.validUsername(arg.substr(1)) && arg.startsWith('*')) 234 | return_username = arg; 235 | }); 236 | 237 | if(message.guild && user_ign){ 238 | let members = message.guild.members.cache.array(); 239 | 240 | args.forEach(function(arg){ 241 | let matching_members = []; 242 | 243 | members.forEach(member => { 244 | if(module.exports.simplifyUsername(member.user.username) == module.exports.simplifyUsername(arg)) 245 | matching_members.push(member.id); 246 | }); 247 | 248 | matching_members.forEach(member => { 249 | if(member in user_ign) 250 | return_username = user_ign[member]; 251 | }); 252 | }); 253 | } 254 | 255 | args.forEach(function(arg){ 256 | if(arg.startsWith("<@")){ 257 | let user_id = arg.substr(2).split(">")[0].replace('!', ''); 258 | 259 | if(user_ign && user_id in user_ign) 260 | return_username = user_ign[user_id]; 261 | } 262 | }); 263 | 264 | if(!return_username){ 265 | if(message.author.id in user_ign) 266 | return_username = user_ign[message.author.id]; 267 | } 268 | 269 | if(config.debug) 270 | module.exports.log('returning data for username', return_username); 271 | 272 | return return_username; 273 | }, 274 | 275 | updateLastBeatmap: (recent, channel_id, last_beatmap) => { 276 | last_beatmap[channel_id] = { 277 | beatmap_id: recent.beatmap_id, 278 | mods: recent.mods, 279 | acc: recent.acc, 280 | fail_percent: recent.fail_percent, 281 | rank: recent.rank 282 | }; 283 | 284 | if(recent.score_id) 285 | last_beatmap[channel_id].score_id = recent.score_id; 286 | 287 | module.exports.setItem('last_beatmap', JSON.stringify(last_beatmap)); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "resolveJsonModule": true 5 | }, 6 | "exclude": ["node_modules", "**/node_modules/*"] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowabot", 3 | "description": "Modular Discord bot with fun features including twitch commands and advanced osu! commands.", 4 | "version": "1.0.0", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "git pull; npm i && [ ! -f ./config.json ] && npm run config; node index", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "webui": "node renderer/webui.js", 10 | "commands": "node generate-commands-md", 11 | "config": "node generate-config", 12 | "emojis": "node upload-emojis" 13 | }, 14 | "author": "LeaPhant", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/LeaPhant/flowabot.git" 18 | }, 19 | "license": "MIT", 20 | "dependencies": { 21 | "@dmzoneill/urban-dictionary": "^3.0.3", 22 | "@twurple/api": "^7.2.1", 23 | "@twurple/auth": "^7.2.1", 24 | "axios": "^0.21.1", 25 | "booba": "^0.0.16", 26 | "bparser-js": "^1.0.2", 27 | "canvas": "^2.11.2", 28 | "chalk": "^4.1.0", 29 | "chart.js": "^2.9.4", 30 | "chartjs-node-canvas": "^3.1.0", 31 | "cheerio": "^1.0.0-rc.6", 32 | "discord.js": "^12.5.3", 33 | "diskusage": "^1.2.0", 34 | "ffmpeg-static": "^4.3.0", 35 | "form-data": "^4.0.0", 36 | "jimp": "^0.16.1", 37 | "lodash": "^4.17.21", 38 | "luxon": "^1.26.0", 39 | "lzma-native": "^7.0.1", 40 | "mathjs": "^9.3.0", 41 | "mysql": "^2.18.1", 42 | "node-emoji": "^1.10.0", 43 | "node-fetch": "^2.6.6", 44 | "node-localstorage": "^2.1.6", 45 | "node-osr": "github:LeaPhant/node-osr", 46 | "object-path": "^0.11.5", 47 | "ojsama": "^2.2.0", 48 | "openweather-apis": "^4.4.2", 49 | "osu-parser": "git+https://github.com/LeaPhant/osu-parser.git", 50 | "readline-sync": "^1.4.10", 51 | "rosu-pp-js": "^3.0.0", 52 | "semver": "^7.3.5", 53 | "table": "^6.7.5", 54 | "tcp-ping": "^0.1.1", 55 | "tz-lookup": "^6.1.25", 56 | "unzipper": "^0.10.11", 57 | "vm2": "^3.9.3" 58 | }, 59 | "devDependencies": { 60 | "finalhandler": "^1.3.1", 61 | "polka": "^0.5.2", 62 | "serve-static": "^1.16.2", 63 | "ws": "^8.18.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /renderer/beatmap/followpoints.js: -------------------------------------------------------------------------------- 1 | const { vectorSubtract, vectorLength, vectorAdd, vectorMultiply } = require('./util'); 2 | 3 | const SPACING = 32; 4 | 5 | const PREEMPT = 800; 6 | const PREEMPT_MIN = 450; 7 | 8 | class FollowpointProcessor { 9 | Beatmap; 10 | Followpoints = []; 11 | 12 | constructor (Beatmap) { 13 | this.Beatmap = Beatmap; 14 | } 15 | 16 | getFadeTimes (start, end, fraction) { 17 | const startTime = start.endTime; 18 | const duration = end.startTime - startTime; 19 | 20 | const preempt = PREEMPT * Math.min(1, this.Beatmap.TimePreempt / PREEMPT_MIN); 21 | 22 | const fadeOutTime = startTime + fraction * duration; 23 | const fadeInTime = fadeOutTime - preempt; 24 | 25 | return { fadeInTime, fadeOutTime }; 26 | } 27 | 28 | getFollowpoints (start, end) { 29 | const { Beatmap } = this; 30 | 31 | const followpoints = []; 32 | 33 | let startPosition = start.endPosition; 34 | 35 | if (start.objectName == 'slider' && start.repeatCount % 2 == 0) 36 | startPosition = start.position; 37 | 38 | const endPosition = end.position; 39 | 40 | const distanceVector = vectorSubtract(endPosition, startPosition); 41 | const distance = vectorLength(distanceVector); 42 | const rotation = Math.atan2(distanceVector[1], distanceVector[0]); 43 | 44 | for (let d = SPACING * 1.5; d < distance - SPACING; d += SPACING) { 45 | const fraction = d / distance; 46 | const pointStartPosition = vectorAdd(startPosition, vectorMultiply(distanceVector, fraction - 0.1)); 47 | const pointEndPosition = vectorAdd(startPosition, vectorMultiply(distanceVector, fraction)); 48 | 49 | const { fadeInTime, fadeOutTime } = this.getFadeTimes(start, end, fraction); 50 | 51 | const fp = { 52 | startPosition: pointStartPosition, 53 | endPosition: pointEndPosition, 54 | rotation 55 | }; 56 | 57 | // actual impl: fadeInTime 58 | fp.fadeInStart = Math.max(fadeInTime, start.startTime - Beatmap.TimePreempt); 59 | fp.fadeInEnd = fp.fadeInStart + Beatmap.TimeFadein; 60 | fp.fadeOutStart = Math.min(fp.fadeInEnd, end.startTime - Beatmap.HitWindow50); 61 | // actual impl: fp.fadeOutStart + (fadeOutTime - fadeInTime) 62 | fp.fadeOutEnd = Math.min(fp.fadeOutStart + (fadeOutTime - fadeInTime), end.startTime); 63 | 64 | followpoints.push(fp); 65 | } 66 | 67 | return followpoints; 68 | } 69 | 70 | process () { 71 | const { Beatmap, Followpoints } = this; 72 | 73 | for (let i = 0; i < Beatmap.hitObjects.length - 1; i++) { 74 | const start = Beatmap.hitObjects[i]; 75 | const end = Beatmap.hitObjects[i + 1]; 76 | 77 | if (end.newCombo || start.objectName == 'spinner' || end.objectName == 'spinner') 78 | continue; 79 | 80 | const followpoints = this.getFollowpoints(start, end); 81 | 82 | Followpoints.push(...followpoints); 83 | } 84 | 85 | Beatmap.Followpoints = Followpoints; 86 | } 87 | } 88 | 89 | const applyFollowpoints = Beatmap => { 90 | new FollowpointProcessor(Beatmap).process(); 91 | }; 92 | 93 | module.exports = applyFollowpoints; -------------------------------------------------------------------------------- /renderer/beatmap/hitsounds.js: -------------------------------------------------------------------------------- 1 | const { getTimingPoint } = require('./util'); 2 | 3 | const sampleSetToName = sampleSetId => { 4 | switch(sampleSetId){ 5 | case 2: 6 | return "soft"; 7 | case 3: 8 | return "drum"; 9 | default: 10 | return "normal"; 11 | } 12 | }; 13 | 14 | const getHitSounds = (timingPoint, name, soundTypes, additions) => { 15 | let output = []; 16 | 17 | let sampleSetName = sampleSetToName(timingPoint.sampleSetId); 18 | let sampleSetNameAddition = sampleSetName; 19 | 20 | if(!soundTypes.includes('normal')) 21 | soundTypes.push('normal'); 22 | 23 | if('sample' in additions) 24 | sampleSetName = additions.sample; 25 | 26 | if('additionalSample' in additions) 27 | sampleSetNameAddition = additions.additionalSample; 28 | 29 | let hitSoundBase = `${sampleSetName}-${name}`; 30 | let hitSoundBaseAddition = `${sampleSetNameAddition}-${name}`; 31 | let customSampleIndex = timingPoint.customSampleIndex > 0 ? timingPoint.customSampleIndex : ''; 32 | 33 | if(name == 'hit'){ 34 | soundTypes.forEach(soundType => { 35 | let base = soundType == 'normal' ? hitSoundBase : hitSoundBaseAddition; 36 | output.push( 37 | `${base}${soundType}${customSampleIndex}` 38 | ); 39 | }); 40 | }else if(name == 'slider'){ 41 | output.push( 42 | `${hitSoundBase}slide${customSampleIndex}` 43 | ); 44 | 45 | if(soundTypes.includes('whistle')) 46 | output.push( 47 | `${hitSoundBase}whistle${customSampleIndex}` 48 | ); 49 | }else if(name == 'slidertick'){ 50 | output.push( 51 | `${hitSoundBase}${customSampleIndex}` 52 | ) 53 | } 54 | 55 | return output; 56 | }; 57 | 58 | class HitsoundProcessor { 59 | Beatmap; 60 | 61 | constructor (Beatmap) { 62 | this.Beatmap = Beatmap; 63 | } 64 | 65 | process (hitObject) { 66 | const { Beatmap } = this; 67 | 68 | const timingPoint = getTimingPoint(Beatmap.timingPoints, hitObject.startTime); 69 | 70 | hitObject.HitSounds = getHitSounds(timingPoint, 'hit', hitObject.soundTypes, hitObject.additions); 71 | hitObject.EdgeHitSounds = []; 72 | hitObject.SliderHitSounds = []; 73 | 74 | if (hitObject.objectName != 'slider') return; 75 | 76 | for (const [i, edge] of hitObject.edges.entries()) { 77 | let offset = i * (hitObject.duration / hitObject.repeatCount) 78 | 79 | let edgeTimingPoint = getTimingPoint(Beatmap.timingPoints, hitObject.startTime + offset); 80 | 81 | hitObject.EdgeHitSounds.push( 82 | getHitSounds(edgeTimingPoint, 'hit', edge.soundTypes, edge.additions) 83 | ); 84 | 85 | hitObject.SliderHitSounds.push( 86 | getHitSounds(edgeTimingPoint, 'slider', hitObject.soundTypes, hitObject.additions) 87 | ); 88 | } 89 | 90 | for (const tick of hitObject.SliderTicks) { 91 | for (let i = 0; i < hitObject.repeatCount; i++){ 92 | if (i == 0) 93 | tick.HitSounds = []; 94 | 95 | let edgeOffset = i * (hitObject.duration / hitObject.repeatCount); 96 | let offset = edgeOffset + (i % 2 == 0 ? tick.offset : tick.reverseOffset); 97 | 98 | let tickTimingPoint = getTimingPoint(Beatmap.timingPoints, hitObject.startTime + offset); 99 | 100 | tick.HitSounds.push( 101 | getHitSounds(tickTimingPoint, 'slidertick', hitObject.soundTypes, hitObject.additions) 102 | ); 103 | } 104 | } 105 | } 106 | } 107 | 108 | const applyHitsounds = Beatmap => { 109 | const hitsoundProcessor = new HitsoundProcessor(Beatmap); 110 | 111 | for (const hitObject of Beatmap.hitObjects) 112 | hitsoundProcessor.process(hitObject); 113 | }; 114 | 115 | module.exports = applyHitsounds; -------------------------------------------------------------------------------- /renderer/beatmap/mods/mods.js: -------------------------------------------------------------------------------- 1 | const ApplicableMods = { 2 | RandomMod: require('./random'), 3 | ReflectionMod: require('./reflection') 4 | }; 5 | 6 | const DefaultMods = Object.values(ApplicableMods); 7 | 8 | const applyMods = (Beatmap, ...EnabledMods) => { 9 | for (const Mod of EnabledMods ?? DefaultMods) { 10 | new Mod(Beatmap).apply(); 11 | } 12 | } 13 | 14 | module.exports = { applyMods, ApplicableMods }; -------------------------------------------------------------------------------- /renderer/beatmap/mods/reflection.js: -------------------------------------------------------------------------------- 1 | const { PLAYFIELD_WIDTH, PLAYFIELD_HEIGHT } = require("../util"); 2 | 3 | class ReflectionMod { 4 | Beatmap; 5 | 6 | constructor (Beatmap) { 7 | this.Beatmap = Beatmap; 8 | } 9 | 10 | invert (axis, length) { 11 | for (const hitObject of this.Beatmap.hitObjects) { 12 | hitObject.position[axis] = length - hitObject.position[axis]; 13 | 14 | if (hitObject.objectName != 'slider') continue; 15 | 16 | for (const point of hitObject.points) 17 | point[axis] = length - point[axis]; 18 | } 19 | } 20 | 21 | apply () { 22 | const mirrorMod = this.Beatmap.Mods.get('MR'); 23 | 24 | // MR.reflection: undefined = horizontal, 1 = vertical, 2 = both 25 | const invertVertical = 26 | this.Beatmap.Mods.has('HR') || 27 | mirrorMod?.reflection >= 1; 28 | 29 | const invertHorizontal = 30 | this.Beatmap.Mods.has('MR') && 31 | mirrorMod?.reflection != 1; // undefined equals 0 32 | 33 | if (invertVertical) 34 | this.invert(1, PLAYFIELD_HEIGHT); 35 | 36 | if (invertHorizontal) 37 | this.invert(0, PLAYFIELD_WIDTH); 38 | } 39 | } 40 | 41 | module.exports = ReflectionMod; -------------------------------------------------------------------------------- /renderer/beatmap/pp.js: -------------------------------------------------------------------------------- 1 | const rosu = require('rosu-pp-js'); 2 | const _ = require('lodash'); 3 | 4 | class CounterProcessor { 5 | Beatmap; 6 | osuContents; 7 | 8 | constructor (Beatmap, osuContents, mods_raw) { 9 | this.Beatmap = Beatmap; 10 | this.osuContents = osuContents; 11 | this.mods_raw = mods_raw; 12 | } 13 | 14 | async calculate () { 15 | const { Beatmap, osuContents, mods_raw } = this; 16 | 17 | let isSetOnLazer = Beatmap.Replay?.isSetOnLazer || false; 18 | let isUsingSliderHeadAccuracy = !(Beatmap.Mods.get('CL')?.no_slider_head_accuracy ?? false); 19 | if (Beatmap.options.stable) { 20 | isUsingSliderHeadAccuracy = false; 21 | isSetOnLazer = false; 22 | } 23 | if (Beatmap.options.lazer) { 24 | isUsingSliderHeadAccuracy = true; 25 | isSetOnLazer = true; 26 | } 27 | 28 | let rosu_mods = mods_raw; 29 | if (Beatmap.options.lazer) { 30 | rosu_mods = mods_raw.filter(mod => mod.acronym !== "CL"); 31 | } 32 | 33 | const rosu_map = new rosu.Beatmap(osuContents); 34 | const rosu_diff = new rosu.Difficulty({ 35 | mods: rosu_mods, 36 | clockRate: Beatmap.SpeedMultiplier, 37 | lazer: isSetOnLazer, 38 | }); 39 | const rosu_perf = rosu_diff.gradualPerformance(rosu_map); 40 | 41 | const scoringFrames = Beatmap.ScoringFrames.filter(a => ['miss', 50, 100, 300].includes(a.result)); 42 | 43 | for (const sf of scoringFrames) { 44 | const hitCount = sf.countMiss + sf.count50 + sf.count100 + sf.count300; 45 | 46 | if (hitCount < Beatmap.firstHitobjectIndex) continue; 47 | if (hitCount >= Beatmap.lastHitobjectIndex && hitCount != Beatmap.hitObjects.length) continue; 48 | 49 | let params = { 50 | maxCombo: sf.maxCombo, 51 | n300: sf.count300, 52 | n100: sf.count100, 53 | n50: sf.count50, 54 | misses: sf.countMiss 55 | }; 56 | 57 | let numerator = 300 * sf.count300 + 100 * sf.count100 + 50 * sf.count50; 58 | let denominator = 300 * hitCount; 59 | 60 | if (isSetOnLazer) { 61 | params = { 62 | osuLargeTickHits: sf.largeTickHits, 63 | osuSmallTickHits: sf.smallTickHits, 64 | sliderEndHits: sf.sliderEndHits, 65 | ...params, 66 | } 67 | 68 | const maxSliderEndHits = sf.sliderEndHits + sf.sliderEndMisses; 69 | const maxLargeTickHits = sf.largeTickMisses + sf.largeTickHits; 70 | const maxSmallTickHits = sf.smallTickMisses + sf.smallTickHits; 71 | 72 | if (isUsingSliderHeadAccuracy) { 73 | const sliderEndHits = Math.min(sf.sliderEndHits, maxSliderEndHits); 74 | const largeTickHits = Math.min(sf.largeTickHits, maxLargeTickHits); 75 | 76 | numerator += 150 * sliderEndHits + 30 * largeTickHits; 77 | denominator += 150 * maxSliderEndHits + 30 * maxLargeTickHits; 78 | } else { 79 | const largeTickHits = Math.min(sf.largeTickHits, maxLargeTickHits); 80 | const smallTickHits = maxSmallTickHits; 81 | 82 | numerator += 30 * largeTickHits + 10 * smallTickHits; 83 | denominator += 30 * largeTickHits + 10 * maxSmallTickHits; 84 | } 85 | } 86 | 87 | sf.accuracy = numerator / denominator * 100; 88 | 89 | let perfResult; 90 | if (hitCount == Beatmap.firstHitobjectIndex || hitCount == Beatmap.hitObjects.length) 91 | perfResult = rosu_perf.nth(params, hitCount); 92 | else 93 | perfResult = rosu_perf.next(params); 94 | 95 | const pp = perfResult?.pp ?? 0; 96 | const stars = perfResult?.difficulty.stars ?? 0; 97 | 98 | sf.pp = pp; 99 | sf.stars = stars; 100 | } 101 | } 102 | 103 | async backfill () { 104 | const { Beatmap } = this; 105 | let pp = 0, stars = 0, accuracy = 100; 106 | 107 | for(const scoringFrame of Beatmap.ScoringFrames){ 108 | if(scoringFrame.pp != null){ 109 | ({pp, stars, accuracy} = scoringFrame) 110 | } 111 | 112 | scoringFrame.pp = pp; 113 | scoringFrame.stars = stars; 114 | scoringFrame.accuracy = accuracy; 115 | } 116 | 117 | const hitResults = _.countBy(Beatmap.ScoringFrames, 'result'); 118 | 119 | hitResults.ur = Beatmap.ScoringFrames[Beatmap.ScoringFrames.length - 1].ur; 120 | 121 | Beatmap.HitResults = hitResults; 122 | 123 | Beatmap.Replay.Mods = [...Beatmap.Mods.keys()]; 124 | } 125 | 126 | async process() { 127 | await this.calculate(); 128 | await this.backfill() 129 | } 130 | } 131 | 132 | const applyCounter = async (Beatmap, osuContents, mods_raw) => { 133 | await new CounterProcessor(Beatmap, osuContents, mods_raw).process(); 134 | }; 135 | 136 | module.exports = applyCounter; 137 | -------------------------------------------------------------------------------- /renderer/beatmap/stacking.js: -------------------------------------------------------------------------------- 1 | const { vectorDistance } = require('./util'); 2 | 3 | const STACK_DISTANCE = 3; 4 | 5 | class StackingProcessor { 6 | Beatmap; 7 | 8 | constructor (Beatmap) { 9 | this.Beatmap = Beatmap; 10 | } 11 | 12 | setOffsets () { 13 | const { Beatmap } = this; 14 | 15 | let startIndex = 0; 16 | let endIndex = Beatmap.hitObjects.length - 1; 17 | 18 | let extendedEndIndex = endIndex; 19 | let extendedStartIndex = startIndex; 20 | 21 | for(let i = extendedEndIndex; i > startIndex; i--){ 22 | let n = i; 23 | 24 | let objectI = Beatmap.hitObjects[i]; 25 | 26 | if(objectI.StackHeight != 0 || objectI.objectName == 'spinner') 27 | continue; 28 | 29 | if(objectI.objectName == 'circle'){ 30 | while(--n >= 0){ 31 | const objectN = Beatmap.hitObjects[n]; 32 | 33 | if(objectN.objectName == 'spinner') 34 | continue; 35 | 36 | const { endTime } = objectN; 37 | 38 | if(objectI.startTime - endTime > Beatmap.StackThreshold) 39 | break; 40 | 41 | if(n < extendedStartIndex){ 42 | objectN.StackHeight = 0; 43 | extendedStartIndex = n; 44 | } 45 | 46 | if(objectN.objectName == 'slider' && vectorDistance(objectN.endPosition, objectI.position) < STACK_DISTANCE){ 47 | const offset = objectI.StackHeight - objectN.StackHeight + 1; 48 | 49 | for(let j = n + 1; j <= i; j++){ 50 | const objectJ = Beatmap.hitObjects[j]; 51 | 52 | if(vectorDistance(objectN.endPosition, objectJ.position) < STACK_DISTANCE) 53 | objectJ.StackHeight -= offset; 54 | } 55 | 56 | break; 57 | } 58 | 59 | if(vectorDistance(objectN.position, objectI.position) < STACK_DISTANCE){ 60 | objectN.StackHeight = objectI.StackHeight + 1; 61 | objectI = objectN; 62 | } 63 | } 64 | }else if(objectI.objectName == 'slider'){ 65 | while(--n >= startIndex){ 66 | const objectN = Beatmap.hitObjects[n]; 67 | 68 | if(objectN.objectName == 'spinner') 69 | continue; 70 | 71 | if(objectI.startTime - objectN.startTime > Beatmap.StackThreshold) 72 | break; 73 | 74 | if(vectorDistance(objectN.endPosition, objectI.position) < STACK_DISTANCE){ 75 | objectN.StackHeight = objectI.StackHeight + 1; 76 | objectI = objectN; 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | setLegacyOffsets () { 84 | const { Beatmap } = this; 85 | 86 | for (let i = 0; i < Beatmap.hitObjects.length; i++){ 87 | const currHitObject = Beatmap.hitObjects[i]; 88 | 89 | if (currHitObject.StackHeight != 0 && currHitObject.objectName != 'slider') 90 | continue; 91 | 92 | let startTime = currHitObject.endTime; 93 | let sliderStack = 0; 94 | 95 | for (let j = i + 1; j < Beatmap.hitObjects.length; j++){ 96 | if(Beatmap.hitObjects[j].startTime - Beatmap.StackThreshold > startTime) 97 | break; 98 | 99 | const position2 = currHitObject.position; 100 | 101 | if (vectorDistance(Beatmap.hitObjects[j].position, currHitObject.position) < STACK_DISTANCE) { 102 | currHitObject.StackHeight++; 103 | startTime = Beatmap.hitObjects[j].endTime; 104 | } else if (vectorDistance(Beatmap.hitObjects[j].position, position2) < STACK_DISTANCE){ 105 | sliderStack++; 106 | Beatmap.hitObjects[j].StackHeight -= sliderStack; 107 | startTime = Beatmap.hitObjects[j].endTime; 108 | } 109 | } 110 | } 111 | } 112 | 113 | applyOffsets () { 114 | const { Beatmap } = this; 115 | 116 | for (const hitObject of this.Beatmap.hitObjects) { 117 | hitObject.StackOffset = hitObject.StackHeight * Beatmap.Scale * -6.4; 118 | hitObject.position = [hitObject.position[0] + hitObject.StackOffset, hitObject.position[1] + hitObject.StackOffset]; 119 | 120 | if (hitObject.objectName != 'slider') continue; 121 | 122 | hitObject.endPosition = [ 123 | hitObject.endPosition[0] + hitObject.StackOffset, 124 | hitObject.endPosition[1] + hitObject.StackOffset 125 | ]; 126 | 127 | for (const dot of hitObject.SliderDots) { 128 | if(!Array.isArray(dot) || dot.length != 2) 129 | continue; 130 | 131 | dot[0] += hitObject.StackOffset; 132 | dot[1] += hitObject.StackOffset; 133 | } 134 | 135 | for (const tick of hitObject.SliderTicks) { 136 | if(!Array.isArray(tick.position) || tick.position.length != 2) 137 | continue; 138 | 139 | tick[0] += hitObject.StackOffset; 140 | tick[1] += hitObject.StackOffset; 141 | } 142 | } 143 | } 144 | 145 | process () { 146 | const { Beatmap } = this; 147 | 148 | for (const hitObject of Beatmap.hitObjects) { 149 | hitObject.StackHeight = 0; 150 | 151 | if (hitObject.objectName != 'circle') 152 | continue; 153 | 154 | hitObject.endTime = hitObject.startTime; 155 | hitObject.endPosition = hitObject.position; 156 | } 157 | 158 | Beatmap.StackThreshold = Beatmap.TimePreempt * Beatmap.StackLeniency; 159 | 160 | const fileFormat = Number(Beatmap.fileFormat.slice(1)); 161 | 162 | if (fileFormat >= 6) { 163 | this.setOffsets(); 164 | } else { 165 | this.setLegacyOffsets(); 166 | } 167 | 168 | this.applyOffsets(); 169 | } 170 | } 171 | 172 | const applyStacking = Beatmap => { 173 | new StackingProcessor(Beatmap).process(); 174 | }; 175 | 176 | module.exports = applyStacking; -------------------------------------------------------------------------------- /renderer/beatmap/util.js: -------------------------------------------------------------------------------- 1 | /* 2 | MATH AND PRECISION 3 | */ 4 | const { fround: float } = Math; 5 | const int = x => Math.imul(x, 1); 6 | 7 | const MathF = { 8 | PI: float(Math.PI), 9 | atan2: (y, x) => float(Math.atan2(y, x)), 10 | sin: (x) => float(Math.sin(x)), 11 | cos: (x) => float(Math.cos(x)) 12 | }; 13 | 14 | const FLOAT_EPSILON = 1e-3; 15 | 16 | const AlmostEquals = (value1, value2, acceptableDifference = FLOAT_EPSILON) => Math.abs(value1 - value2) <= acceptableDifference; 17 | const clamp = (number, min, max) => Math.max(Math.min(number, max), min); 18 | 19 | /* 20 | VECTOR OPERATIONS 21 | */ 22 | const vectorF = (v) => { 23 | return [ 24 | float(v[0]), 25 | float(v[1]) 26 | ]; 27 | }; 28 | 29 | const vectorLength = (v) => { 30 | return Math.sqrt(v[0] ** 2 + v[1] ** 2); 31 | }; 32 | 33 | const vectorFLength = (v) => { 34 | return float(vectorLength(v)); 35 | }; 36 | 37 | const vectorDistance = (a, b) => { 38 | return Math.sqrt((b[0] - a[0]) * (b[0] - a[0]) 39 | + (b[1] - a[1]) * (b[1] - a[1])); 40 | }; 41 | 42 | const vectorFDistance = (a, b) => { 43 | return float(vectorDistance(a, b)); 44 | }; 45 | 46 | const vectorEquals = (a, b) => { 47 | return a[0] == b[0] && a[1] == b[1]; 48 | }; 49 | 50 | const vectorSubtract = (a, b) => { 51 | return [ 52 | a[0] - b[0], 53 | a[1] - b[1] 54 | ]; 55 | }; 56 | 57 | const vectorFSubtract = (a, b) => { 58 | return vectorF(vectorSubtract(a, b)); 59 | }; 60 | 61 | const vectorAdd = (a, b) => { 62 | return [ 63 | a[0] + b[0], 64 | a[1] + b[1] 65 | ]; 66 | }; 67 | 68 | const vectorFAdd = (a, b) => { 69 | return vectorF(vectorAdd(a, b)); 70 | }; 71 | 72 | const vectorMultiply = (a, m) => { 73 | return [ 74 | a[0] * m, 75 | a[1] * m 76 | ]; 77 | }; 78 | 79 | const vectorFMultiply = (a, b) => { 80 | return vectorF(vectorMultiply(a, b)); 81 | }; 82 | 83 | const vectorDivide = (a, d) => { 84 | return [ 85 | a[0] / d, 86 | a[1] / d 87 | ]; 88 | }; 89 | 90 | const vectorFDivide = (a, b) => { 91 | return vectorF(vectorDivide(a, b)); 92 | }; 93 | 94 | const vectorRotate = (v, rotation) => { 95 | const angle = Math.atan2(v[1], v[0]) + rotation; 96 | const length = vectorLength(v); 97 | return [ 98 | length * Math.cos(angle), 99 | length * Math.sin(angle) 100 | ]; 101 | }; 102 | 103 | const vectorFRotate = (v, rotation) => { 104 | const angle = MathF.atan2(v[1], v[0]) + rotation; 105 | const length = vectorFLength(v); 106 | return [ 107 | length * MathF.cos(angle), 108 | length * MathF.sin(angle) 109 | ]; 110 | }; 111 | 112 | const vectorDistanceSquared = (a, b) => { 113 | return (b[0] - a[0]) * (b[0] - a[0]) 114 | + (b[1] - a[1]) * (b[1] - a[1]); 115 | }; 116 | 117 | const vectorFDistanceSquared = (a, b) => { 118 | return float(vectorDistanceSquared(a, b)); 119 | }; 120 | 121 | /* 122 | CONSTANTS 123 | */ 124 | const PLAYFIELD_WIDTH = 512; 125 | const PLAYFIELD_HEIGHT = 384; 126 | const PLAYFIELD_DIAGONAL_REAL = vectorLength([PLAYFIELD_WIDTH, PLAYFIELD_HEIGHT]); 127 | const PLAYFIELD_DIAGONAL = 640.995056; 128 | const PLAYFIELD_CENTER = [PLAYFIELD_WIDTH / 2, PLAYFIELD_HEIGHT / 2]; 129 | 130 | const PLAYFIELD_EDGE_RATIO = 0.375; 131 | const BORDER_DISTANCE_X = PLAYFIELD_WIDTH * PLAYFIELD_EDGE_RATIO; 132 | const BORDER_DISTANCE_Y = PLAYFIELD_HEIGHT * PLAYFIELD_EDGE_RATIO; 133 | 134 | /* 135 | BEATMAP UTILITIES 136 | */ 137 | const difficultyRange = (difficulty, min, mid, max) => { 138 | if (min === undefined) 139 | return (difficulty - 5) / 5; 140 | 141 | let result; 142 | 143 | if(difficulty > 5) 144 | result = mid + (max - mid) * (difficulty - 5) / 5; 145 | else if(difficulty < 5) 146 | result = mid + (mid - min) * (difficulty - 5) / 5; 147 | else 148 | result = mid 149 | 150 | return float(result); 151 | }; 152 | 153 | const getTimingPoint = (timingPoints, offset, redLines = false) => { 154 | let timingPoint = timingPoints[0]; 155 | 156 | for(let x = timingPoints.length - 1; x >= 0; x--){ 157 | if(redLines && !timingPoints[x].timingChange) continue; 158 | 159 | if(timingPoints[x].offset <= offset){ 160 | timingPoint = timingPoints[x]; 161 | break; 162 | } 163 | } 164 | 165 | return timingPoint; 166 | }; 167 | 168 | /* 169 | C# PRNG 170 | */ 171 | const INT32_MIN_VALUE = -0x80000000; 172 | const INT32_MAX_VALUE = 0x7fffffff; 173 | 174 | // https://source.dot.net/#System.Private.CoreLib/src/libraries/System.Private.CoreLib/src/System/Random.CompatImpl.cs,241 175 | class Random { 176 | _seedArray; 177 | _inext; 178 | _inextp; 179 | 180 | constructor(seed) { 181 | this.seed = int(seed); 182 | 183 | let seedArray = new Array(56); 184 | 185 | let subtraction = int((seed == INT32_MIN_VALUE) ? INT32_MAX_VALUE : Math.abs(seed)); 186 | let mj = int(161803398 - subtraction); // magic number based on Phi (golden ratio) 187 | seedArray[55] = mj; 188 | let mk = 1; 189 | 190 | let ii = 0; 191 | for (let i = 1; i < 55; i++) 192 | { 193 | // The range [1..55] is special (Knuth) and so we're wasting the 0'th position. 194 | if ((ii += 21) >= 55) 195 | { 196 | ii = ii - 55; 197 | } 198 | 199 | seedArray[ii] = mk; 200 | mk = int(mj - mk); 201 | if (mk < 0) 202 | { 203 | mk = int(mk + INT32_MAX_VALUE); 204 | } 205 | 206 | mj = int(seedArray[ii]); 207 | } 208 | 209 | for (let k = 1; k < 5; k++) 210 | { 211 | for (let i = 1; i < 56; i++) 212 | { 213 | let n = i + 30; 214 | if (n >= 55) 215 | { 216 | n -= 55; 217 | } 218 | 219 | seedArray[i] = int(seedArray[i] - seedArray[1 + n]); 220 | if (seedArray[i] < 0) 221 | { 222 | seedArray[i] = int(seedArray[i] + INT32_MAX_VALUE); 223 | } 224 | } 225 | } 226 | 227 | this._seedArray = seedArray; 228 | this._inext = 0; 229 | this._inextp = 21; 230 | } 231 | 232 | sample () { 233 | let sample = this.InternalSample() * (1.0 / INT32_MAX_VALUE); 234 | return sample; 235 | } 236 | 237 | InternalSample () { 238 | let locINext = this._inext; 239 | 240 | if (++locINext >= 56) 241 | { 242 | locINext = 1; 243 | } 244 | 245 | let locINextp = this._inextp; 246 | if (++locINextp >= 56) 247 | { 248 | locINextp = 1; 249 | } 250 | 251 | let seedArray = this._seedArray; 252 | let retVal = int(seedArray[locINext] - seedArray[locINextp]); 253 | 254 | if (retVal == INT32_MAX_VALUE) 255 | { 256 | retVal = int(retVal - 1); 257 | } 258 | if (retVal < 0) 259 | { 260 | retVal = int(retVal + INT32_MAX_VALUE); 261 | } 262 | 263 | seedArray[locINext] = retVal; 264 | this._inext = locINext; 265 | this._inextp = locINextp; 266 | 267 | return retVal; 268 | } 269 | } 270 | 271 | module.exports = { 272 | MathF, float, int, 273 | INT32_MAX_VALUE, INT32_MIN_VALUE, 274 | AlmostEquals, clamp, 275 | vectorF, 276 | vectorLength, vectorFLength, 277 | vectorDistance, vectorFDistance, 278 | vectorEquals, 279 | vectorSubtract, vectorFSubtract, 280 | vectorAdd, vectorFAdd, 281 | vectorMultiply, vectorFMultiply, 282 | vectorDivide, vectorFDivide, 283 | vectorRotate, vectorFRotate, 284 | vectorDistanceSquared, vectorFDistanceSquared, 285 | PLAYFIELD_WIDTH, PLAYFIELD_HEIGHT, PLAYFIELD_CENTER, 286 | PLAYFIELD_DIAGONAL, PLAYFIELD_DIAGONAL_REAL, BORDER_DISTANCE_X, BORDER_DISTANCE_Y, 287 | difficultyRange, getTimingPoint, 288 | Random 289 | }; -------------------------------------------------------------------------------- /renderer/beatmap/worker.js: -------------------------------------------------------------------------------- 1 | const processBeatmap = require('./process'); 2 | 3 | process.on('message', async obj => { 4 | const { beatmap_path, options, mods_raw, time, length } = obj; 5 | const beatmap = await processBeatmap(beatmap_path, options, mods_raw, time, length); 6 | 7 | process.send(beatmap, () => { 8 | process.exit(0); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /renderer/res/argon/drum-hitclap.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/drum-hitclap.wav -------------------------------------------------------------------------------- /renderer/res/argon/drum-hitfinish.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/drum-hitfinish.wav -------------------------------------------------------------------------------- /renderer/res/argon/drum-hitnormal.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/drum-hitnormal.wav -------------------------------------------------------------------------------- /renderer/res/argon/drum-hitwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/drum-hitwhistle.wav -------------------------------------------------------------------------------- /renderer/res/argon/drum-sliderslide.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/drum-sliderslide.wav -------------------------------------------------------------------------------- /renderer/res/argon/drum-slidertick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/drum-slidertick.wav -------------------------------------------------------------------------------- /renderer/res/argon/drum-sliderwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/drum-sliderwhistle.wav -------------------------------------------------------------------------------- /renderer/res/argon/drum-spinnerbonus.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/drum-spinnerbonus.wav -------------------------------------------------------------------------------- /renderer/res/argon/drum-spinnerspin.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/drum-spinnerspin.wav -------------------------------------------------------------------------------- /renderer/res/argon/normal-hitclap.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/normal-hitclap.wav -------------------------------------------------------------------------------- /renderer/res/argon/normal-hitfinish.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/normal-hitfinish.wav -------------------------------------------------------------------------------- /renderer/res/argon/normal-hitnormal.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/normal-hitnormal.wav -------------------------------------------------------------------------------- /renderer/res/argon/normal-hitwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/normal-hitwhistle.wav -------------------------------------------------------------------------------- /renderer/res/argon/normal-sliderslide.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/normal-sliderslide.wav -------------------------------------------------------------------------------- /renderer/res/argon/normal-slidertick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/normal-slidertick.wav -------------------------------------------------------------------------------- /renderer/res/argon/normal-sliderwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/normal-sliderwhistle.wav -------------------------------------------------------------------------------- /renderer/res/argon/normal-spinnerbonus.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/normal-spinnerbonus.wav -------------------------------------------------------------------------------- /renderer/res/argon/normal-spinnerspin.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/normal-spinnerspin.wav -------------------------------------------------------------------------------- /renderer/res/argon/soft-hitclap.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/soft-hitclap.wav -------------------------------------------------------------------------------- /renderer/res/argon/soft-hitfinish.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/soft-hitfinish.wav -------------------------------------------------------------------------------- /renderer/res/argon/soft-hitnormal.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/soft-hitnormal.wav -------------------------------------------------------------------------------- /renderer/res/argon/soft-hitwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/soft-hitwhistle.wav -------------------------------------------------------------------------------- /renderer/res/argon/soft-sliderslide.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/soft-sliderslide.wav -------------------------------------------------------------------------------- /renderer/res/argon/soft-slidertick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/soft-slidertick.wav -------------------------------------------------------------------------------- /renderer/res/argon/soft-sliderwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/soft-sliderwhistle.wav -------------------------------------------------------------------------------- /renderer/res/argon/soft-spinnerbonus.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/soft-spinnerbonus.wav -------------------------------------------------------------------------------- /renderer/res/argon/soft-spinnerspin.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/argon/soft-spinnerspin.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/LICENSE: -------------------------------------------------------------------------------- 1 | These hitsounds are provided by the osu!stable default skin and their usage has been approved of by peppy. 2 | Licensing of the respective projects applies. 3 | 4 | https://osu.ppy.sh/community/forums/topics/129191 5 | https://osu.ppy.sh/home 6 | -------------------------------------------------------------------------------- /renderer/res/hitsounds/combobreak.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/combobreak.mp3 -------------------------------------------------------------------------------- /renderer/res/hitsounds/drum-hitclap.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/drum-hitclap.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/drum-hitfinish.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/drum-hitfinish.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/drum-hitnormal.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/drum-hitnormal.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/drum-hitwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/drum-hitwhistle.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/drum-sliderslide.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/drum-sliderslide.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/drum-slidertick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/drum-slidertick.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/drum-sliderwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/drum-sliderwhistle.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/normal-hitclap.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/normal-hitclap.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/normal-hitfinish.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/normal-hitfinish.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/normal-hitnormal.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/normal-hitnormal.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/normal-hitwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/normal-hitwhistle.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/normal-sliderslide.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/normal-sliderslide.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/normal-slidertick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/normal-slidertick.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/normal-sliderwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/normal-sliderwhistle.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/soft-hitclap.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/soft-hitclap.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/soft-hitfinish.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/soft-hitfinish.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/soft-hitnormal.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/soft-hitnormal.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/soft-hitwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/soft-hitwhistle.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/soft-sliderslide.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/soft-sliderslide.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/soft-slidertick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/soft-slidertick.wav -------------------------------------------------------------------------------- /renderer/res/hitsounds/soft-sliderwhistle.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/hitsounds/soft-sliderwhistle.wav -------------------------------------------------------------------------------- /renderer/res/images/arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /renderer/res/lagtrain.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/renderer/res/lagtrain.mp3 -------------------------------------------------------------------------------- /renderer/ur.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const fs = require('fs').promises; 4 | const path = require('path'); 5 | const os = require('os'); 6 | const { fork } = require('child_process'); 7 | const config = require('../config.json'); 8 | const { calculate_ur } = require("./ur_processor"); 9 | 10 | function calculateUr(options){ 11 | return new Promise(async (resolve, reject) => { 12 | 13 | let replay_path = path.resolve(config.replay_path, `${options.score_id}.osr`); 14 | let replay_exists = await fs.stat(replay_path).then(() => true, () => false); 15 | 16 | if(!replay_exists) { 17 | const response = await axios.get(`https://osu.ppy.sh/api/v2/scores/${options.score_id}/download`, { 18 | responseType: 'arraybuffer', 19 | headers: { 20 | 'Authorization': 'Bearer ' + options.access_token, 21 | 'Content-Type': 'application/x-osu-replay' 22 | } 23 | }).catch(error => console.log(error)); 24 | 25 | const replay_raw = response.data 26 | //const replay_raw = Buffer.from(response.data.content, "base64"); 27 | 28 | await fs.writeFile(path.resolve(config.replay_path, `${options.score_id}.osr`), replay_raw, { encoding: 'binary' }); 29 | } 30 | 31 | const ur = await calculate_ur({ 32 | beatmap_path: path.resolve(config.osu_cache_path, `${options.beatmap_id}.osu`), 33 | options, 34 | enabled_mods: options.mods 35 | }) 36 | 37 | resolve({ ur: ur }); 38 | }); 39 | } 40 | 41 | module.exports = { 42 | get_ur: calculateUr 43 | }; 44 | -------------------------------------------------------------------------------- /renderer/webui.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | const http = require('http'); 3 | const os = require('node:os'); 4 | const serveStatic = require('serve-static'); 5 | const finalHandler = require('finalhandler'); 6 | const path = require('path'); 7 | const renderCommand = require('../commands/render'); 8 | const { promises: fs } = require('fs'); 9 | 10 | const HTTP_PORT = 7270; 11 | const WS_PORT = 7271; 12 | 13 | const wss = new WebSocket.WebSocketServer({ port: WS_PORT }); 14 | 15 | const serve = serveStatic(path.join(__dirname, 'webui')); 16 | 17 | const server = http.createServer(function onRequest (req, res) { 18 | serve(req, res, finalHandler(req, res)) 19 | }); 20 | 21 | server.listen(HTTP_PORT); 22 | 23 | console.log(`Listening on http://localhost:${HTTP_PORT}`); 24 | 25 | let currentProgress; 26 | 27 | process.on('SIGINT', () => { 28 | try {require('fs').rmSync(path.resolve(__dirname, 'webui', 'output'), { recursive: true }); } catch (e) {} 29 | process.exit(0); 30 | }); 31 | 32 | const msg = { 33 | edit: async content => { 34 | return progressEvent(content); 35 | }, 36 | delete: async () => { 37 | return msg; 38 | }, 39 | channel: { 40 | id: 0, 41 | send: async content => { 42 | if (content?.embed === undefined && content?.files === undefined) 43 | return errorEvent(content); 44 | 45 | if (content?.files === undefined) 46 | return progressEvent(content); 47 | 48 | return (await completeEvent(content)); 49 | } 50 | } 51 | }; 52 | 53 | const broadcast = (event, data) => { 54 | for (const client of wss.clients) { 55 | if (client.readyState !== WebSocket.OPEN) continue; 56 | client.send(JSON.stringify({ event, data })); 57 | } 58 | } 59 | 60 | const progressEvent = content => { 61 | broadcast('progress', content?.embed?.description); 62 | return msg; 63 | }; 64 | 65 | const completeEvent = async content => { 66 | try { 67 | const name = content?.files?.[0].name ?? 'video.mp4'; 68 | const videoPath = path.resolve(__dirname, 'webui', 'output'); 69 | try { await fs.mkdir(videoPath); } catch(e) {} 70 | await fs.copyFile(content?.files?.[0].attachment, path.resolve(videoPath, name)); 71 | broadcast('complete', `output/${name}`); 72 | return msg; 73 | } catch(err) { 74 | console.error(err); 75 | return errorEvent(err); 76 | } 77 | }; 78 | 79 | const errorEvent = content => { 80 | broadcast('error', content); 81 | return msg; 82 | }; 83 | 84 | const render = (command) => { 85 | const argv = command.split(' '); 86 | 87 | renderCommand.call({ 88 | argv, 89 | msg, 90 | last_beatmap: { 0: {} }, 91 | webui: true 92 | }); 93 | }; 94 | 95 | wss.on('connection', ws => { 96 | ws.on('error', console.error); 97 | 98 | ws.on('message', command => { 99 | render(command.toString()); 100 | }); 101 | }); -------------------------------------------------------------------------------- /renderer/webui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 31 | 32 | 33 |
    34 |
    35 | 36 |
    37 |
    38 |

    Progress:

    39 |
    ...
    40 |
    41 |
    42 |

    Output:

    43 |
    44 |
    45 | 46 | 47 | -------------------------------------------------------------------------------- /renderer/webui/index.js: -------------------------------------------------------------------------------- 1 | const inputCommand = document.getElementById('inp_command'); 2 | const elemProgress = document.getElementById('elem_progress'); 3 | const elemOutput = document.getElementById('elem_output'); 4 | 5 | const socket = new WebSocket("ws://localhost:7271"); 6 | 7 | socket.addEventListener("message", (payload) => { 8 | const { event, data } = JSON.parse(payload.data); 9 | 10 | console.log(event, data); 11 | 12 | if (event == 'progress') { 13 | elemProgress.innerHTML = data; 14 | } 15 | 16 | if (event == 'complete') { 17 | let outputElement; 18 | 19 | if (data.endsWith('.mp4')) { 20 | outputElement = document.createElement('video'); 21 | outputElement.controls = true; 22 | outputElement.autoplay = true; 23 | } 24 | 25 | if (data.endsWith('.gif')) { 26 | outputElement = document.createElement('img'); 27 | } 28 | 29 | if (!outputElement) return; 30 | 31 | outputElement.src = `${data}?${Date.now()}`; 32 | elemOutput.replaceChildren(outputElement); 33 | } 34 | }); 35 | 36 | const render = () => { 37 | socket.send(inputCommand.value); 38 | } 39 | 40 | inputCommand.addEventListener('keydown', event => { 41 | if (event.code == 'Enter') { 42 | event.preventDefault(); 43 | return render(); 44 | } 45 | }); -------------------------------------------------------------------------------- /res/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaPhant/flowabot/26301a5112e413813c925346032d5978b02db3b2/res/logo.png -------------------------------------------------------------------------------- /upload-emojis.js: -------------------------------------------------------------------------------- 1 | const Discord = require('discord.js'); 2 | const fs = require('fs').promises; 3 | const path = require('path'); 4 | const readline = require('readline'); 5 | 6 | const rl = readline.createInterface({ 7 | input: process.stdin, 8 | output: process.stdout 9 | }); 10 | 11 | const helper = require('./helper.js'); 12 | 13 | const client = new Discord.Client({autoReconnect:true}); 14 | 15 | client.on('error', console.error); 16 | 17 | const config = require('./config.json'); 18 | 19 | let guilds = []; 20 | 21 | client.on('ready', () => { 22 | client.guilds.cache.array().forEach(guild => { 23 | if(guild.me.hasPermission('MANAGE_EMOJIS')) 24 | guilds.push(guild); 25 | }); 26 | 27 | if(guilds.length == 0) 28 | throw "Bot has no servers to upload emotes to"; 29 | 30 | guilds.forEach((guild, index) => { 31 | let staticEmojis = guild.emojis.cache.filter(a => !a.animated && !a.deleted && !a.managed).array().length; 32 | console.log(index, `${guild.name} - ${staticEmojis} / 50 or more emote slots`); 33 | }); 34 | 35 | rl.question('Server to upload the emotes to (server index): ', answer => { 36 | let index = Number(answer); 37 | 38 | if(isNaN(index)) 39 | throw "Input is not a number"; 40 | 41 | if(index < 0 || index >= guilds.length) 42 | throw "Invalid range, please choose a valid server index"; 43 | 44 | let guild = guilds[index]; 45 | 46 | fs.readdir('./emotes').then(files => { 47 | let promises = []; 48 | 49 | files.forEach(file => { 50 | if(path.extname(file) == '.png') 51 | promises.push( 52 | guild.emojis.create(`./emotes/${file}`, path.basename(file, path.extname(file))) 53 | ); 54 | }); 55 | 56 | Promise.all(promises).then(() => { 57 | console.log(`${promises.length} emotes successfully uploaded!`); 58 | rl.close(); 59 | process.exit(0); 60 | }).catch(err => { 61 | throw err; 62 | }); 63 | }).catch(err => { 64 | throw err; 65 | }); 66 | }); 67 | }); 68 | 69 | client.login(config.credentials.bot_token); 70 | --------------------------------------------------------------------------------