├── .eslintignore ├── .eslintrc ├── .example.env ├── .gitattributes ├── .gitignore ├── .prettierrc ├── README.md ├── nodemon.json ├── package-lock.json ├── package.json ├── public ├── DT1.png ├── DT2.png ├── DT3.png ├── DT4.png ├── FM1.png ├── FM2.png ├── FM3.png ├── FM4.png ├── HD1.png ├── HD2.png ├── HD3.png ├── HD4.png ├── HR1.png ├── HR2.png ├── HR3.png ├── HR4.png ├── NM1.png ├── NM2.png ├── NM3.png ├── NM4.png ├── NM5.png ├── NM6.png ├── NM7.png ├── NM8.png ├── TB.png ├── TB1.png ├── TB2.png └── TB3.png ├── src ├── api │ ├── Kitsu │ │ ├── apiResponseType.ts │ │ ├── getBeatmapInfo.ts │ │ ├── getBeatmapSetInfo.ts │ │ ├── index.ts │ │ └── searchBeatmapSet.ts │ ├── Pools │ │ ├── index.ts │ │ └── recommend.ts │ └── index.ts ├── client │ └── index.ts ├── commands │ ├── debug │ │ ├── index.ts │ │ └── ping.ts │ ├── general │ │ ├── help.ts │ │ └── index.ts │ ├── index.ts │ └── osu │ │ ├── beatmap.ts │ │ ├── index.ts │ │ ├── recommend.ts │ │ ├── search.ts │ │ └── set.ts ├── db │ ├── index.ts │ └── pools.ts ├── events │ ├── index.ts │ ├── interactionCreate │ │ ├── commands.ts │ │ ├── help.ts │ │ └── index.ts │ └── ready.ts ├── index.ts ├── keys │ └── index.ts ├── pages │ ├── embeds │ │ ├── beatmap.ts │ │ ├── beatmapSet.ts │ │ ├── error.ts │ │ ├── index.ts │ │ └── tourneyMap.ts │ ├── help.ts │ └── index.ts ├── scripts │ └── deploy.ts ├── types │ ├── commands.ts │ ├── events.ts │ ├── index.ts │ ├── keys.ts │ └── poolType.ts └── utils │ ├── Calc │ ├── calculatePP.ts │ └── index.ts │ ├── chunk.ts │ ├── command.ts │ ├── event.ts │ ├── index.ts │ ├── interaction.ts │ ├── replies.ts │ └── secondsToMinutes.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | /src/db/pools.ts 4 | *.md -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 12, 5 | "sourceType": "module" 6 | }, 7 | "plugins": ["@typescript-eslint"], 8 | // HERE 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier" 13 | ], 14 | 15 | "rules": { 16 | "@typescript-eslint/no-unused-vars": "error", 17 | "@typescript-eslint/consistent-type-definitions": ["error", "type"] 18 | }, 19 | 20 | "env": { 21 | "browser": true, 22 | "es2021": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | CLIENT_TOKEN="client_token" 2 | TEST_GUILD="1234567890" 3 | KITSU_API_LINK="https://kitsu.moe/api" 4 | OSU_API_KEY="osu_api_key" -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | /src/db/pools.ts 4 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": false, 4 | "arrowParens": "always", 5 | "trailingComma": "none", 6 | "printWidth": 150, 7 | "tabWidth": 2, 8 | "bracketSpacing": true 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | [Bocchi shaking her head] 3 |

4 | 5 | # Bocchi Bot (Work in Progress) 6 | 7 | bocchi-Bot :wave:
8 | Discord bot for osu! tournament training practice. 9 | 10 | ### Todo 11 | 12 | - [ ] Transfer fake db to real db (sql) 13 | - [ ] Host the discord bot 14 | - [ ] Better error handling 15 | - [ ] Generate a tournament pool 16 | 17 | _More features on the way!_ 18 | 19 | ### Completed ✓ 20 | 21 | - [x] Reccomend a beatmap based on specified query (mmr, skillset) 22 | - [x] Calculate beatmap stats(sr, bpm, etc.) after mod application 23 | - [x] Search for a beatmap by name 24 | - [x] Display beatmap set info and all difficulties 25 | - [x] Display single beatmap info 26 | - [x] API to Download beatmap (even graveyard maps) [KITSU](https://kitsu.moe) 27 | - [x] Bocchi profile pic 28 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": ["node_modules/"], 4 | "watch": ["src/"], 5 | "execMap": { 6 | "ts": "node -r ts-node/register" 7 | }, 8 | "env": { 9 | "NODE_ENV": "development" 10 | }, 11 | "ext": "js,json,ts" 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bocchi", 3 | "version": "1.0.0", 4 | "description": "Discord multi-purpose bot built with Typescript", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "start": "node .", 9 | "dev": "nodemon --config nodemon.json src/index.ts", 10 | "deploy": "cross-env IS_SCRIPT=true ts-node src/scripts/deploy", 11 | "deploy-prod": "cross-env NODE_ENV=production npm run deploy", 12 | "lint": "eslint --ignore-path .eslintignore --ext .js,.ts .", 13 | "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json)\"" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@types/node": "^18.11.9", 20 | "@typescript-eslint/eslint-plugin": "^5.44.0", 21 | "@typescript-eslint/parser": "^5.44.0", 22 | "eslint": "^8.28.0", 23 | "eslint-config-prettier": "^8.5.0", 24 | "nodemon": "^2.0.20", 25 | "prettier": "^2.8.0", 26 | "ts-node": "^10.9.1", 27 | "typescript": "^4.9.3" 28 | }, 29 | "dependencies": { 30 | "@rian8337/osu-base": "^2.2.0", 31 | "@rian8337/osu-difficulty-calculator": "^2.2.0", 32 | "axios": "^1.1.3", 33 | "cross-env": "^7.0.3", 34 | "discord.js": "^14.6.0", 35 | "dotenv": "^16.0.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/DT1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/DT1.png -------------------------------------------------------------------------------- /public/DT2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/DT2.png -------------------------------------------------------------------------------- /public/DT3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/DT3.png -------------------------------------------------------------------------------- /public/DT4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/DT4.png -------------------------------------------------------------------------------- /public/FM1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/FM1.png -------------------------------------------------------------------------------- /public/FM2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/FM2.png -------------------------------------------------------------------------------- /public/FM3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/FM3.png -------------------------------------------------------------------------------- /public/FM4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/FM4.png -------------------------------------------------------------------------------- /public/HD1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/HD1.png -------------------------------------------------------------------------------- /public/HD2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/HD2.png -------------------------------------------------------------------------------- /public/HD3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/HD3.png -------------------------------------------------------------------------------- /public/HD4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/HD4.png -------------------------------------------------------------------------------- /public/HR1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/HR1.png -------------------------------------------------------------------------------- /public/HR2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/HR2.png -------------------------------------------------------------------------------- /public/HR3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/HR3.png -------------------------------------------------------------------------------- /public/HR4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/HR4.png -------------------------------------------------------------------------------- /public/NM1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/NM1.png -------------------------------------------------------------------------------- /public/NM2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/NM2.png -------------------------------------------------------------------------------- /public/NM3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/NM3.png -------------------------------------------------------------------------------- /public/NM4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/NM4.png -------------------------------------------------------------------------------- /public/NM5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/NM5.png -------------------------------------------------------------------------------- /public/NM6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/NM6.png -------------------------------------------------------------------------------- /public/NM7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/NM7.png -------------------------------------------------------------------------------- /public/NM8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/NM8.png -------------------------------------------------------------------------------- /public/TB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/TB.png -------------------------------------------------------------------------------- /public/TB1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/TB1.png -------------------------------------------------------------------------------- /public/TB2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/TB2.png -------------------------------------------------------------------------------- /public/TB3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantelli/bocchi-discord-bot/dcde05e9c50abe36a3a9845670343199ba183918/public/TB3.png -------------------------------------------------------------------------------- /src/api/Kitsu/apiResponseType.ts: -------------------------------------------------------------------------------- 1 | export type BeatmapResponseApi = { 2 | ParentSetID: number 3 | BeatmapID: number 4 | TotalLength: number 5 | HitLength: number 6 | DiffName: string 7 | FileMD5: string 8 | CS: number 9 | AR: number 10 | HP: number 11 | OD: number 12 | Mode: number 13 | BPM: number 14 | Playcount: number 15 | Passcount: number 16 | MaxCombo: number 17 | DifficultyRating: number 18 | } 19 | 20 | export type BeatmapSetResponseApi = { 21 | SetID: number 22 | Title: string 23 | Artist: string 24 | Creator: string 25 | Source: string 26 | Tags: string 27 | RankedStatus: number 28 | Genre: number 29 | Language: number 30 | Favourites: number 31 | HasVideo: boolean 32 | DownloadUnavailable: boolean 33 | ApprovedDate: string 34 | LastUpdate: string 35 | LastChecked: string 36 | ChildrenBeatmaps: BeatmapResponseApi[] 37 | } 38 | 39 | export type SearchBeatmapSetResponseApi = BeatmapSetResponseApi[] 40 | -------------------------------------------------------------------------------- /src/api/Kitsu/getBeatmapInfo.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import keys from "../../keys" 3 | import { BeatmapResponseApi } from "./apiResponseType" 4 | 5 | export const getBeatmapInfo = async (beatmapId: string): Promise => { 6 | try { 7 | const response = await axios.get(keys.apiLink + "/b/" + beatmapId) 8 | return response.data as BeatmapResponseApi 9 | } catch (err) { 10 | throw new Error("Beatmap not found") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/api/Kitsu/getBeatmapSetInfo.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import keys from "../../keys" 3 | import { BeatmapSetResponseApi } from "./apiResponseType" 4 | 5 | export const getBeatmapSetInfo = async (beatmapSetId: string): Promise => { 6 | try { 7 | const response = await axios.get(keys.apiLink + "/s/" + beatmapSetId) 8 | return response.data as BeatmapSetResponseApi 9 | } catch (err) { 10 | throw new Error("Beatmap set not found") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/api/Kitsu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./apiResponseType" 2 | export * from "./getBeatmapInfo" 3 | export * from "./getBeatmapSetInfo" 4 | export * from "./searchBeatmapSet" 5 | -------------------------------------------------------------------------------- /src/api/Kitsu/searchBeatmapSet.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import keys from "../../keys" 3 | import { SearchBeatmapSetResponseApi } from "./apiResponseType" 4 | 5 | export const searchBeatmapSet = async (searchQuery: string, rankedStatus?: boolean): Promise => { 6 | const params = { 7 | query: searchQuery, 8 | mode: 0, 9 | amount: 10 10 | } 11 | 12 | try { 13 | if (rankedStatus === undefined) { 14 | const response = await axios.get(keys.apiLink + "/search", { params: { ...params } }) 15 | return response.data as SearchBeatmapSetResponseApi 16 | } 17 | const response = await axios.get(keys.apiLink + "/search", { params: { ...params, status: 1 } }) 18 | return response.data as SearchBeatmapSetResponseApi 19 | } catch (err) { 20 | throw new Error("No beatmaps found with the provided search query") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/api/Pools/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./recommend" 2 | -------------------------------------------------------------------------------- /src/api/Pools/recommend.ts: -------------------------------------------------------------------------------- 1 | import { poolData } from "../../db" 2 | import { TourneyMap } from "../../types" 3 | 4 | export const getRecommendedBeatmap = (mmr: number, mod: string): TourneyMap => { 5 | const variance = 100 6 | const pool = poolData.filter((pool) => pool.averageMMR >= mmr - variance && pool.averageMMR <= mmr + variance) 7 | const randomPool = pool[Math.floor(Math.random() * pool.length)] 8 | if (!pool) { 9 | throw new Error("No pool found for mmr") 10 | } 11 | const map = randomPool.maps.filter((map) => map.mod === mod) 12 | const randomMap = map[Math.floor(Math.random() * map.length)] 13 | if (!map) { 14 | throw new Error("No map found for mod") 15 | } 16 | 17 | return randomMap 18 | } 19 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Kitsu" 2 | export * from "./Pools" 3 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | import { Client, GatewayIntentBits } from "discord.js" 2 | import events from "../events" 3 | import keys from "../keys" 4 | import { registerEvents } from "../utils" 5 | 6 | const client = new Client({ 7 | intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] 8 | }) 9 | 10 | registerEvents(client, events) 11 | 12 | client.login(keys.clientToken).catch((err) => { 13 | console.log("[Login error]:", err) 14 | process.exit(1) 15 | }) 16 | -------------------------------------------------------------------------------- /src/commands/debug/index.ts: -------------------------------------------------------------------------------- 1 | import { category } from "../../utils" 2 | import ping from "./ping" 3 | 4 | export default category("Debug", [ping], { 5 | description: "Commands for debugging the bot.", 6 | emoji: "🐛" 7 | }) 8 | -------------------------------------------------------------------------------- /src/commands/debug/ping.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "discord.js" 2 | import { command } from "../../utils" 3 | 4 | const meta = new SlashCommandBuilder() 5 | .setName("ping") 6 | .setDescription("Replies with pong!") 7 | .addStringOption((option) => 8 | option.setName("message").setDescription("The message the both will reply with.").setMinLength(1).setMaxLength(2000).setRequired(false) 9 | ) 10 | 11 | export default command(meta, ({ interaction }) => { 12 | const message = interaction.options.getString("message") 13 | interaction.reply({ 14 | ephemeral: true, 15 | content: message ?? "Pong! 🏓" 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/commands/general/help.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "discord.js" 2 | import { getCategoryRoot } from "../../pages/" 3 | import { command } from "../../utils" 4 | 5 | const meta = new SlashCommandBuilder().setName("help").setDescription("Shows a list of all commands or info about a specific command.") 6 | 7 | export default command(meta, ({ interaction }) => { 8 | interaction.reply(getCategoryRoot(true)) 9 | }) 10 | -------------------------------------------------------------------------------- /src/commands/general/index.ts: -------------------------------------------------------------------------------- 1 | import { category } from "../../utils" 2 | import help from "./help" 3 | 4 | export default category("General", [help], { 5 | emoji: "📚", 6 | description: "General commands for the bot." 7 | }) 8 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import debug from "./debug" 2 | import general from "./general" 3 | import osu from "./osu" 4 | 5 | export default [debug, general, osu] 6 | -------------------------------------------------------------------------------- /src/commands/osu/beatmap.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "discord.js" 2 | import { getBeatmapInfo } from "../../api" 3 | import { beatmapEmbed, errorEmbed } from "../../pages/embeds" 4 | import { command } from "../../utils" 5 | 6 | const meta = new SlashCommandBuilder() 7 | .setName("beatmap") 8 | .setDescription("Get beatmap info") 9 | .addStringOption((option) => option.setName("beatmapid").setDescription("The id of the beatmap").setRequired(true)) 10 | 11 | export default command(meta, async ({ interaction }) => { 12 | const beatmapIdInput = interaction.options.getString("beatmapid") 13 | 14 | try { 15 | if (beatmapIdInput) { 16 | const data = await getBeatmapInfo(beatmapIdInput) 17 | return interaction.reply(beatmapEmbed(data)) 18 | } 19 | } catch (err) { 20 | return interaction.reply(errorEmbed("Beatmap ID is invalid")) 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/commands/osu/index.ts: -------------------------------------------------------------------------------- 1 | import { category } from "../../utils" 2 | import beatmap from "./beatmap" 3 | import recommend from "./recommend" 4 | import search from "./search" 5 | import set from "./set" 6 | 7 | export default category("Osu", [recommend, search, beatmap, set], { 8 | emoji: "🏆", 9 | description: "Osu commands for the bot." 10 | }) 11 | -------------------------------------------------------------------------------- /src/commands/osu/recommend.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "discord.js" 2 | import { getBeatmapInfo, getRecommendedBeatmap } from "../../api" 3 | import { errorEmbed, tourneyMapEmbed } from "../../pages/embeds" 4 | import { Mods } from "../../types" 5 | import { command, calculatePP } from "../../utils" 6 | 7 | const meta = new SlashCommandBuilder() 8 | .setName("r") 9 | .setDescription("Recommend a beatmap from a random tourney pool") 10 | .addNumberOption((option) => 11 | option.setName("mmr").setDescription("The average mmr of the beatmap").setRequired(true).setMinValue(100).setMaxValue(3300) 12 | ) 13 | .addStringOption((option) => 14 | option.setName("mod").setDescription("The mod of the beatmap").setRequired(true).addChoices( 15 | { 16 | name: "No Mod", 17 | value: Mods.NM 18 | }, 19 | { 20 | name: "Hidden", 21 | value: Mods.HD 22 | }, 23 | { 24 | name: "Hard Rock", 25 | value: Mods.HR 26 | }, 27 | { 28 | name: "Double Time", 29 | value: Mods.DT 30 | }, 31 | { 32 | name: "Free Mod", 33 | value: Mods.FM 34 | }, 35 | { 36 | name: "Tie Breaker", 37 | value: Mods.TB 38 | } 39 | ) 40 | ) 41 | 42 | export default command(meta, async ({ interaction }) => { 43 | const mmr = interaction.options.getNumber("mmr") 44 | const mod = interaction.options.getString("mod") 45 | const beatmap = getRecommendedBeatmap(mmr!, mod!) 46 | const beatmapResponse = await getBeatmapInfo(beatmap.mapId.toString()) 47 | 48 | try { 49 | if (beatmap.mapName.length > 0) { 50 | const rating = await calculatePP(beatmap.mapId, beatmap.sheetId.slice(0, -1)) 51 | return interaction.reply( 52 | tourneyMapEmbed(beatmapResponse, rating!.pp.total, beatmap.sheetId, beatmap.mapName, rating!.map.stats, rating!.map.sr) 53 | ) 54 | } 55 | } catch (err) { 56 | return interaction.reply(errorEmbed("Beatmap not found")) 57 | } 58 | }) 59 | -------------------------------------------------------------------------------- /src/commands/osu/search.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "discord.js" 2 | import { searchBeatmapSet } from "../../api" 3 | import { beatmapSetEmbed, errorEmbed } from "../../pages" 4 | import { command } from "../../utils" 5 | 6 | const meta = new SlashCommandBuilder() 7 | .setName("search") 8 | .setDescription("Search for a beatmap") 9 | .addStringOption((option) => option.setName("name").setDescription("The name of the beatmap").setRequired(true)) 10 | .addBooleanOption((option) => option.setName("ranked").setDescription("Show ranked maps only").setRequired(false)) 11 | 12 | export default command(meta, async ({ interaction }) => { 13 | const query = interaction.options.getString("name") 14 | const ranked = interaction.options.getBoolean("ranked") 15 | 16 | try { 17 | if (ranked) { 18 | // TODO: need to make component to display data 19 | const data = await searchBeatmapSet(query!, ranked) 20 | return interaction.reply(beatmapSetEmbed(data[0])) 21 | } 22 | const data = await searchBeatmapSet(query!) 23 | return interaction.reply(beatmapSetEmbed(data[0])) 24 | } catch (err) { 25 | return interaction.reply(errorEmbed("Beatmap ID is invalid")) 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /src/commands/osu/set.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "discord.js" 2 | import { getBeatmapSetInfo } from "../../api" 3 | import { beatmapSetEmbed, errorEmbed } from "../../pages/embeds" 4 | import { command } from "../../utils" 5 | 6 | const meta = new SlashCommandBuilder() 7 | .setName("set") 8 | .setDescription("Get beatmap set info") 9 | .addStringOption((option) => option.setName("setid").setDescription("The id of the beatmap set").setRequired(true)) 10 | 11 | export default command(meta, async ({ interaction }) => { 12 | const beatmapSetIdInput = interaction.options.getString("setid") 13 | 14 | try { 15 | if (beatmapSetIdInput) { 16 | const data = await getBeatmapSetInfo(beatmapSetIdInput) 17 | return interaction.reply(beatmapSetEmbed(data)) 18 | } 19 | } catch (err) { 20 | return interaction.reply(errorEmbed("Beatmap ID is invalid")) 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./pools" 2 | -------------------------------------------------------------------------------- /src/events/index.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "../types" 2 | import interactionCreate from "./interactionCreate" 3 | import ready from "./ready" 4 | 5 | const events: Event[] = [ready, ...interactionCreate] 6 | 7 | export default events 8 | -------------------------------------------------------------------------------- /src/events/interactionCreate/commands.ts: -------------------------------------------------------------------------------- 1 | import commands from "../../commands" 2 | import { Command } from "../../types" 3 | import { EditReply, event, Reply } from "../../utils" 4 | 5 | const allCommands = commands.map(({ commands }) => commands).flat() 6 | const allCommandsMap = new Map(allCommands.map((c) => [c.meta.name, c])) 7 | 8 | export default event("interactionCreate", async ({ log, client }, interaction) => { 9 | if (!interaction.isChatInputCommand()) return 10 | 11 | try { 12 | const commandName = interaction.commandName 13 | const command = allCommandsMap.get(commandName) 14 | 15 | if (!command) throw new Error("Command not found ...") 16 | 17 | await command.exec({ 18 | client, 19 | interaction, 20 | log(...args) { 21 | log(`[${command.meta.name}]`, ...args) 22 | } 23 | }) 24 | } catch (err) { 25 | log("[Command Error]", err) 26 | 27 | if (interaction.deferred) return interaction.editReply(EditReply.error(`An error occurred while executing this command :(`)) 28 | 29 | return interaction.reply(Reply.error(`An error occurred while executing this command :( \n\`\`\`${err}\`\`\``)) 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /src/events/interactionCreate/help.ts: -------------------------------------------------------------------------------- 1 | import { SelectMenuInteraction } from "discord.js" 2 | import { getCategoryPage, getCategoryRoot, Namespaces } from "../../pages/help" 3 | import { createId, EditReply, event, readId, Reply } from "../../utils" 4 | 5 | export default event("interactionCreate", async ({ log }, interaction) => { 6 | if (!interaction.isButton() && !interaction.isSelectMenu()) return 7 | const [namespace] = readId(interaction.customId) 8 | 9 | // If namespace not in help pages stop 10 | if (!Object.values(Namespaces).includes(namespace)) return 11 | 12 | try { 13 | // Defer update 14 | await interaction.deferUpdate() 15 | 16 | switch (namespace) { 17 | case Namespaces.root: 18 | return await interaction.editReply(getCategoryRoot()) 19 | case Namespaces.select: 20 | const newId = createId(Namespaces.select, (interaction as SelectMenuInteraction).values[0]) 21 | return await interaction.editReply(getCategoryPage(newId)) 22 | case Namespaces.action: 23 | return await interaction.editReply(getCategoryPage(interaction.customId)) 24 | 25 | default: 26 | throw new Error("Invalid namespace reached...") 27 | } 28 | } catch (error) { 29 | log("[Help Error]", error) 30 | 31 | if (interaction.deferred) return interaction.editReply(EditReply.error("Something went wrong :(")) 32 | 33 | return interaction.reply(Reply.error("Something went wrong :(")) 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /src/events/interactionCreate/index.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "../../types" 2 | import commands from "./commands" 3 | import help from "./help" 4 | 5 | const events: Event[] = [commands, help] 6 | 7 | export default events 8 | -------------------------------------------------------------------------------- /src/events/ready.ts: -------------------------------------------------------------------------------- 1 | import { event } from "../utils" 2 | 3 | export default event("ready", ({ log }, client) => { 4 | log(`Logged in as ${client.user.tag}!`) 5 | ;(async () => { 6 | try { 7 | client.user.setPresence({ 8 | // number of servers this bot is in with comma separators 9 | activities: [ 10 | { 11 | name: `osu!`, 12 | type: 0 13 | } 14 | ], 15 | status: "online" 16 | }) 17 | } catch (error) { 18 | // And of course, make sure you catch and log any errors! 19 | console.error(error) 20 | } 21 | })() 22 | }) 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv" 2 | import { resolve } from "path" 3 | 4 | config({ path: resolve(__dirname, "..", ".env") }) 5 | 6 | import "./client" 7 | -------------------------------------------------------------------------------- /src/keys/index.ts: -------------------------------------------------------------------------------- 1 | import { Keys } from "../types" 2 | 3 | const keys: Keys = { 4 | clientToken: process.env.CLIENT_TOKEN ?? "nil", 5 | testGuild: process.env.TEST_GUILD ?? "nil", 6 | apiLink: process.env.KITSU_API_LINK ?? "nil", 7 | osuApiKey: process.env.OSU_API_KEY ?? "nil" 8 | } 9 | 10 | if (Object.values(keys).includes("nil")) { 11 | throw new Error("Missing environment variables") 12 | } 13 | 14 | export default keys 15 | -------------------------------------------------------------------------------- /src/pages/embeds/beatmap.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, InteractionReplyOptions } from "discord.js" 2 | import { BeatmapResponseApi } from "../../api" 3 | import keys from "../../keys" 4 | import { secondsToMinutes } from "../../utils" 5 | 6 | export const beatmapEmbed = (data: BeatmapResponseApi): InteractionReplyOptions => { 7 | const embed = new EmbedBuilder() 8 | .setColor(0xff8ee6) 9 | .setImage(`https://assets.ppy.sh/beatmaps/${data.ParentSetID}/covers/cover.jpg` ?? "https://assets.ppy.sh/beatmaps/355322/covers/cover.jpg") 10 | .setTitle(`Difficulty Name: ${data.DiffName}`) 11 | .setURL(`https://osu.ppy.sh/beatmapsets/${data.ParentSetID}#osu/${data.BeatmapID}`) 12 | .addFields({ 13 | name: `Beatmap Info`, 14 | value: `${data.DifficultyRating}⭐️ - ${data.BPM}bpm - ${secondsToMinutes(data.TotalLength)}⏱️ - x/${data.MaxCombo} combo \n CS ${ 15 | data.CS 16 | } | AR ${data.AR} | HP ${data.HP} | OD ${data.OD}` 17 | }) 18 | .setFooter({ text: `Bocchi Bot - Powered by Kitsu API` }) 19 | .setTimestamp(new Date()) 20 | 21 | const row = new ActionRowBuilder().addComponents( 22 | new ButtonBuilder().setURL(`${keys.apiLink}/d/${data.ParentSetID}`).setLabel("Download Map").setStyle(ButtonStyle.Link) 23 | ) 24 | return { 25 | embeds: [embed], 26 | components: [row] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/embeds/beatmapSet.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, InteractionReplyOptions } from "discord.js" 2 | import { BeatmapSetResponseApi } from "../../api" 3 | import keys from "../../keys" 4 | import { secondsToMinutes } from "../../utils" 5 | 6 | export const beatmapSetEmbed = (data: BeatmapSetResponseApi): InteractionReplyOptions => { 7 | const embed = new EmbedBuilder() 8 | .setColor(0xff8ee6) 9 | .setImage(`https://assets.ppy.sh/beatmaps/${data.SetID}/covers/cover.jpg` ?? "https://assets.ppy.sh/beatmaps/355322/covers/cover.jpg") 10 | .setTitle(`${data.Artist} - ${data.Title}`) 11 | .setDescription(`Mapped By: ${data.Creator}`) 12 | .setURL(`https://osu.ppy.sh/beatmapsets/${data.SetID}#osu/`) 13 | .addFields({ 14 | name: `Difficulties:`, 15 | value: `${data.ChildrenBeatmaps.map( 16 | (beatmap): string => 17 | `**${beatmap.DiffName}** \n ${beatmap.DifficultyRating}⭐️ - ${beatmap.BPM}bpm - ${secondsToMinutes(beatmap.TotalLength)}⏱️ - x/${ 18 | beatmap.MaxCombo 19 | } combo \n CS ${beatmap.CS} | AR ${beatmap.AR} | HP ${beatmap.HP} | OD ${beatmap.OD} \n` 20 | ).join("")}` 21 | }) 22 | .setFooter({ text: `Bocchi Bot - Powered by Kitsu API` }) 23 | .setTimestamp(new Date()) 24 | 25 | const row = new ActionRowBuilder().addComponents( 26 | new ButtonBuilder().setURL(`${keys.apiLink}/d/${data.SetID}`).setLabel("Download Mapset").setStyle(ButtonStyle.Link) 27 | ) 28 | return { 29 | embeds: [embed], 30 | components: [row] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/embeds/error.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, InteractionReplyOptions } from "discord.js" 2 | 3 | export const errorEmbed = (description: string): InteractionReplyOptions => { 4 | const embed = new EmbedBuilder().setColor(0xeb3434).setTitle("Something went wrong :(").setDescription(description) 5 | return { 6 | embeds: [embed], 7 | ephemeral: true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/embeds/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./beatmap" 2 | export * from "./beatmapSet" 3 | export * from "./error" 4 | export * from "./tourneyMap" 5 | -------------------------------------------------------------------------------- /src/pages/embeds/tourneyMap.ts: -------------------------------------------------------------------------------- 1 | import { MapStats } from "@rian8337/osu-base" 2 | import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, InteractionReplyOptions } from "discord.js" 3 | import { BeatmapResponseApi } from "../../api" 4 | import keys from "../../keys" 5 | import { secondsToMinutes } from "../../utils" 6 | 7 | export const tourneyMapEmbed = ( 8 | data: BeatmapResponseApi, 9 | pp: number, 10 | sheetId: string, 11 | mapName: string, 12 | mapStats: MapStats, 13 | starRating: number 14 | ): InteractionReplyOptions => { 15 | const file = new AttachmentBuilder(`./public/${sheetId}.png`) 16 | const embed = new EmbedBuilder() 17 | .setColor(0xff8ee6) 18 | .setImage(`https://assets.ppy.sh/beatmaps/${data.ParentSetID}/covers/cover.jpg` ?? "https://assets.ppy.sh/beatmaps/355322/covers/cover.jpg") 19 | .setTitle(`${mapName} - ${data.DiffName}`) 20 | .setThumbnail(`attachment://${sheetId}.png`) 21 | .setDescription(`**${pp.toFixed(2)}** pp`) 22 | .setURL(`https://osu.ppy.sh/beatmapsets/${data.ParentSetID}#osu/${data.BeatmapID}`) 23 | .addFields({ 24 | name: `Beatmap Info`, 25 | value: `${starRating.toFixed(2)}⭐️ - ${data.BPM * mapStats.speedMultiplier}bpm - ${ 26 | sheetId.slice(0, -1) === "DT" ? secondsToMinutes(data.TotalLength * 0.66) : secondsToMinutes(data.TotalLength) 27 | }⏱️ - x/${data.MaxCombo} combo \n CS ${mapStats.cs!.toFixed(1)} | AR ${mapStats.ar!.toFixed(1)} | OD ${mapStats.od!.toFixed( 28 | 1 29 | )} | HP ${mapStats.hp!.toFixed(1)}` 30 | }) 31 | .setFooter({ text: `Bocchi Bot - Powered by Kitsu API` }) 32 | .setTimestamp(new Date()) 33 | 34 | const row = new ActionRowBuilder().addComponents( 35 | new ButtonBuilder().setURL(`${keys.apiLink}/d/${data.ParentSetID}`).setLabel("Download Mapset").setStyle(ButtonStyle.Link) 36 | ) 37 | return { 38 | embeds: [embed], 39 | components: [row], 40 | files: [file] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/help.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, SelectMenuBuilder } from "@discordjs/builders" 2 | import { APIEmbedField, ButtonStyle, EmbedBuilder, InteractionReplyOptions, SelectMenuOptionBuilder } from "discord.js" 3 | import CategoryRoot from "../commands" 4 | import { chunk, createId, readId } from "../utils" 5 | 6 | // Namespaces we will use 7 | export const Namespaces = { 8 | root: "help_category_root", 9 | select: "help_category_select", 10 | action: "help_category_action" 11 | } 12 | 13 | // Actions we will use 14 | export const Actions = { 15 | next: "+", 16 | back: "-" 17 | } 18 | 19 | const N = Namespaces 20 | const A = Actions 21 | 22 | // Generate root embed for help paginator 23 | export function getCategoryRoot(ephemeral?: boolean): InteractionReplyOptions { 24 | // Map the categories 25 | const mappedCategories = CategoryRoot.map( 26 | ({ name, description, emoji }) => 27 | new SelectMenuOptionBuilder({ 28 | label: name, 29 | description, 30 | emoji, 31 | value: name 32 | }) 33 | ) 34 | 35 | // Create embed 36 | const embed = new EmbedBuilder().setTitle("Help Menu").setDescription("Browse through all commands.") 37 | 38 | // Create select menu for categories 39 | const selectId = createId(N.select) 40 | const select = new SelectMenuBuilder().setCustomId(selectId).setPlaceholder("Command Category").setMaxValues(1).setOptions(mappedCategories) 41 | 42 | const component = new ActionRowBuilder().addComponents(select) 43 | 44 | return { 45 | embeds: [embed], 46 | components: [component], 47 | ephemeral 48 | } 49 | } 50 | 51 | // Generate new embed for current category page 52 | export function getCategoryPage(interactionId: string): InteractionReplyOptions { 53 | // Extract needed metadata from interactionId 54 | const [_namespace, categoryName, action, currentOffset] = readId(interactionId) 55 | 56 | const categoryChunks = CategoryRoot.map((c) => { 57 | // Pre-map all commands as embed fields 58 | const commands: APIEmbedField[] = c.commands.map((c) => ({ 59 | name: c.meta.name, 60 | value: c.meta.description 61 | })) 62 | 63 | return { 64 | ...c, 65 | commands: chunk(commands, 10) 66 | } 67 | }) 68 | 69 | const category = categoryChunks.find(({ name }) => name === categoryName) 70 | if (!category) throw new Error("Invalid interactionId; Failed to find corresponding category page!") 71 | 72 | // Get current offset 73 | let offset = parseInt(currentOffset) 74 | // if is NaN set offset to 0 75 | if (isNaN(offset)) offset = 0 76 | // Increment offset according to action 77 | if (action === A.next) offset++ 78 | else if (action === A.back) offset-- 79 | 80 | const emoji = category.emoji ? `${category.emoji} ` : "" 81 | const defaultDescription = `Browse through ${category.commands.flat().length} commands in ${emoji}${category.name}` 82 | 83 | const embed = new EmbedBuilder() 84 | .setTitle(`${emoji}${category.name} Commands`) 85 | .setDescription(category.description ?? defaultDescription) 86 | .setFields(category.commands[offset]) 87 | .setFooter({ text: `${offset + 1} / ${category.commands.length}` }) 88 | 89 | // Back button 90 | const backId = createId(N.action, category.name, A.back, offset) 91 | const backButton = new ButtonBuilder() 92 | .setCustomId(backId) 93 | .setLabel("Back") 94 | .setStyle(ButtonStyle.Danger) 95 | .setDisabled(offset <= 0) 96 | 97 | // Return to root 98 | const rootId = createId(N.root) 99 | const rootButton = new ButtonBuilder().setCustomId(rootId).setLabel("Categories").setStyle(ButtonStyle.Secondary) 100 | 101 | // Next button 102 | const nextId = createId(N.action, category.name, A.next, offset) 103 | const nextButton = new ButtonBuilder() 104 | .setCustomId(nextId) 105 | .setLabel("Next") 106 | .setStyle(ButtonStyle.Success) 107 | .setDisabled(offset >= category.commands.length - 1) 108 | 109 | const component = new ActionRowBuilder().addComponents(backButton, rootButton, nextButton) 110 | 111 | return { 112 | embeds: [embed], 113 | components: [component] 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./embeds" 2 | export * from "./help" 3 | -------------------------------------------------------------------------------- /src/scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv" 2 | import { resolve } from "path" 3 | 4 | config({ path: resolve(__dirname, "..", "..", ".env") }) 5 | 6 | import { REST, Routes, APIUser } from "discord.js" 7 | import commands from "../commands" 8 | import keys from "../keys" 9 | 10 | const body = commands.map(({ commands }) => commands.map(({ meta }) => meta)).flat() 11 | 12 | const rest = new REST({ version: "10" }).setToken(keys.clientToken) 13 | 14 | async function main() { 15 | const currentUser = (await rest.get(Routes.user())) as APIUser 16 | 17 | const endpoint = 18 | process.env.NODE_ENV === "production" 19 | ? Routes.applicationCommands(currentUser.id) 20 | : Routes.applicationGuildCommands(currentUser.id, keys.testGuild) 21 | 22 | await rest.put(endpoint, { body }) 23 | return currentUser 24 | } 25 | 26 | main() 27 | .then((user) => { 28 | const tag = `${user.username}#${user.discriminator}` 29 | const response = 30 | process.env.NODE_ENV === "production" 31 | ? `Successfully registered application commands for ${tag}!` 32 | : `Successfully registered application commands for development in ${keys.testGuild} as ${tag}!` 33 | 34 | console.log(response) 35 | }) 36 | .catch(console.error) 37 | -------------------------------------------------------------------------------- /src/types/commands.ts: -------------------------------------------------------------------------------- 1 | import { Awaitable, ChatInputCommandInteraction, Client, SlashCommandBuilder } from "discord.js" 2 | 3 | type LoggerFunction = (...args: unknown[]) => void 4 | export interface CommandProps { 5 | interaction: ChatInputCommandInteraction 6 | client: Client 7 | log: LoggerFunction 8 | } 9 | 10 | export type CommandExec = (props: CommandProps) => Awaitable 11 | export type CommandMeta = SlashCommandBuilder | Omit 12 | export interface Command { 13 | meta: CommandMeta 14 | exec: CommandExec 15 | } 16 | 17 | export interface CommandCategoryExtra { 18 | description?: string 19 | emoji?: string 20 | } 21 | 22 | export interface CommandCategory extends CommandCategoryExtra { 23 | name: string 24 | commands: Command[] 25 | } 26 | -------------------------------------------------------------------------------- /src/types/events.ts: -------------------------------------------------------------------------------- 1 | import { ClientEvents, Awaitable, Client } from "discord.js" 2 | 3 | type LoggerFunction = (...args: unknown[]) => void 4 | export interface EventProps { 5 | client: Client 6 | log: LoggerFunction 7 | } 8 | 9 | export type EventKeys = keyof ClientEvents 10 | export type EventExec = (props: EventProps, ...args: ClientEvents[T]) => Awaitable 11 | export interface Event { 12 | id: T 13 | exec: EventExec 14 | } 15 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./commands" 2 | export * from "./events" 3 | export * from "./keys" 4 | export * from "./poolType" 5 | -------------------------------------------------------------------------------- /src/types/keys.ts: -------------------------------------------------------------------------------- 1 | export interface Keys { 2 | clientToken: string 3 | testGuild: string 4 | apiLink: string 5 | osuApiKey: string 6 | } 7 | -------------------------------------------------------------------------------- /src/types/poolType.ts: -------------------------------------------------------------------------------- 1 | export enum Mods { 2 | NM = "NOMOD", 3 | HD = "HIDDEN", 4 | HR = "HARDROCK", 5 | DT = "DOUBLETIME", 6 | FM = "FREEMOD", 7 | TB = "TIEBREAKER" 8 | } 9 | 10 | export interface TourneyMap { 11 | mapId: number 12 | mod: string 13 | mapName: string 14 | difficultyName: string 15 | length: number 16 | starRating: number 17 | mapSetId: number 18 | maxCombo: number 19 | bpm: number 20 | downloadAvailable: boolean 21 | mmr: number 22 | skillset: string 23 | sheetId: string // example: NM1, HD2 24 | } 25 | 26 | export interface TourneyPool { 27 | version: number 28 | name: string 29 | link?: string 30 | averageMMR: number 31 | ranked: boolean 32 | canBeRandomlySelected: boolean 33 | gamemode: string 34 | maps: TourneyMap[] 35 | uuid: string 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/Calc/calculatePP.ts: -------------------------------------------------------------------------------- 1 | import { MapInfo, ModUtil } from "@rian8337/osu-base" 2 | import { MapStars, OsuPerformanceCalculator } from "@rian8337/osu-difficulty-calculator" 3 | 4 | export const calculatePP = async (mapId: number, mods: string) => { 5 | const beatmapInfo = await MapInfo.getInformation(mapId) 6 | 7 | if (!beatmapInfo!.title) { 8 | throw new Error("Beatmap not found") 9 | } 10 | if (beatmapInfo) { 11 | const appliedMods = ModUtil.pcStringToMods(mods) 12 | const newRating = new MapStars(beatmapInfo.beatmap, { 13 | mods: appliedMods 14 | }) 15 | const osuPerformance = new OsuPerformanceCalculator(newRating.osu).calculate() 16 | return { 17 | pp: osuPerformance, 18 | map: { 19 | stats: newRating.osu.stats, 20 | sr: newRating.osu.total 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/Calc/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./calculatePP" 2 | -------------------------------------------------------------------------------- /src/utils/chunk.ts: -------------------------------------------------------------------------------- 1 | // Takes an array of items and chunk items into a matrix. 2 | // Useful for offset based pagination. 3 | export function chunk(items: T[], chunk: number): T[][] { 4 | // Initialize the matrix 5 | const chunks: T[][] = [] 6 | 7 | // For loop; Loop until i is more than our items available; Increment by the given chunk; 8 | // Each iteration will copy push targeted chunk from the pass items to the chunks array 9 | for (let i = 0; i < items.length; i += chunk) { 10 | chunks.push(items.slice(i, i + chunk)) 11 | } 12 | 13 | return chunks 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/command.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandCategory, CommandCategoryExtra, CommandExec, CommandMeta } from "../types" 2 | 3 | export function command(meta: CommandMeta, exec: CommandExec): Command { 4 | return { meta, exec } 5 | } 6 | 7 | export function category(name: string, commands: Command[], extra: CommandCategoryExtra = {}): CommandCategory { 8 | return { name, commands, ...extra } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/event.ts: -------------------------------------------------------------------------------- 1 | import { Event, EventExec, EventKeys } from "../types" 2 | import { Client } from "discord.js" 3 | 4 | export function event(id: T, exec: EventExec): Event { 5 | return { id, exec } 6 | } 7 | 8 | export function registerEvents(client: Client, events: Event[]): void { 9 | for (const event of events) 10 | client.on(event.id, async (...args) => { 11 | const props = { 12 | client, 13 | log: (...args: unknown[]) => console.log(`[${event.id}]:`, ...args) 14 | } 15 | 16 | try { 17 | await event.exec(props, ...args) 18 | } catch (err) { 19 | props.log("Uncaught error:", err) 20 | } 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./chunk" 2 | export * from "./command" 3 | export * from "./event" 4 | export * from "./interaction" 5 | export * from "./replies" 6 | export * from "./secondsToMinutes" 7 | export * from "./Calc" 8 | -------------------------------------------------------------------------------- /src/utils/interaction.ts: -------------------------------------------------------------------------------- 1 | export function createId(namespace: string, ...args: unknown[]): string { 2 | return `${namespace}-${args.join("-")}` 3 | } 4 | 5 | export function readId(id: string): [namespace: string, ...args: string[]] { 6 | const [namespace, ...args] = id.split("-") 7 | return [namespace, ...args] 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/replies.ts: -------------------------------------------------------------------------------- 1 | import { InteractionReplyOptions, WebhookEditMessageOptions } from "discord.js" 2 | 3 | export const Colors = { 4 | error: 0xf54242 5 | } 6 | 7 | export const Reply = { 8 | error(msg: string): InteractionReplyOptions { 9 | return { 10 | ephemeral: true, 11 | embeds: [ 12 | { 13 | color: Colors.error, 14 | description: msg 15 | } 16 | ] 17 | } 18 | } 19 | } 20 | 21 | export const EditReply = { 22 | error(msg: string): WebhookEditMessageOptions { 23 | return { 24 | embeds: [ 25 | { 26 | color: Colors.error, 27 | description: msg 28 | } 29 | ] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/secondsToMinutes.ts: -------------------------------------------------------------------------------- 1 | export function secondsToMinutes(time: number): string { 2 | // minutes:seconds 3 | return Math.floor(time / 60) + ":" + ("0" + Math.floor(time % 60)).slice(-2) 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs" /* Specify what module code is generated. */, 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | "resolveJsonModule": true /* Enable importing .json files. */, 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 77 | 78 | /* Type Checking */ 79 | "strict": true /* Enable all strict type-checking options. */, 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": ["./src/index.ts"] 104 | } 105 | --------------------------------------------------------------------------------