├── .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 |
3 |
4 |
5 | # Bocchi Bot (Work in Progress)
6 |
7 |
: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 |
--------------------------------------------------------------------------------