├── .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("")[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 |
37 |
38 |
Progress:
39 |
...
40 |
41 |
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 |
--------------------------------------------------------------------------------