├── .husky ├── .gitignore └── pre-commit ├── .node-version ├── .prettierignore ├── templates ├── README.md ├── message-handler.ts.hbs └── command.ts.hbs ├── .dockerignore ├── src ├── events │ ├── index.ts │ ├── messageCreate │ │ ├── README.md │ │ ├── handlers │ │ │ ├── index.ts │ │ │ └── trainer-link.ts │ │ └── index.ts │ ├── ready.ts │ └── interactionCreate.ts ├── modules.d.ts ├── config.ts ├── commands │ ├── userinfo.ts │ ├── info.ts │ ├── introduce.ts │ ├── index.ts │ ├── warn.ts │ ├── echo.ts │ ├── link.ts │ ├── leaderboard.ts │ ├── howto.ts │ └── rankup.ts ├── main.ts ├── common.ts └── codewars.ts ├── .gitignore ├── .env.production ├── tsconfig.release.json ├── fly.toml ├── .env.example ├── text ├── warn │ ├── conduct.md │ ├── spam.md │ └── content.md ├── howto │ ├── post_link.md │ ├── create_thread.md │ ├── screenshot.md │ ├── format_code.md │ └── ask_for_help.md └── introduce │ └── help-solve.md ├── .github └── workflows │ ├── deploy.yml │ └── ci.yml ├── tsconfig.json ├── Dockerfile ├── LICENSE ├── plopfile.mjs ├── package.json └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v16.13.2 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | templates/ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx pretty-quick --staged 2 | -------------------------------------------------------------------------------- /templates/README.md: -------------------------------------------------------------------------------- 1 | Templates for code geneneration. 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules/ 4 | lib/ 5 | .git/ 6 | .github/ 7 | -------------------------------------------------------------------------------- /src/events/index.ts: -------------------------------------------------------------------------------- 1 | export { onCommand, onAutocomplete } from "./interactionCreate"; 2 | export { onMessageCreate } from "./messageCreate"; 3 | export { makeOnReady } from "./ready"; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | .vscode 7 | !.vscode/tasks.js 8 | .idea/ 9 | 10 | .npm 11 | node_modules/ 12 | lib/ 13 | 14 | .env.development 15 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # DO NOT ADD SECRETS HERE 2 | # For production, secrets are injected by Heroku Config Vars. 3 | # BOT_TOKEN 4 | # CLIENT_ID 5 | # GUILD_ID 6 | 7 | # Add any other configs. 8 | # CLIENT_ID and GUILD_ID can probably be moved here. 9 | -------------------------------------------------------------------------------- /tsconfig.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "lib", 6 | "removeComments": true 7 | }, 8 | "include": ["src/**/*"], 9 | "exclude": ["src/**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/events/messageCreate/README.md: -------------------------------------------------------------------------------- 1 | # `message` 2 | 3 | ## `onMessage(message: Message) => Promise` 4 | 5 | Event handler for `message` event. Simply delegates to a command or handler(s). 6 | 7 | ## TODO 8 | 9 | - [ ] Document command and handler 10 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for codewars-discord-bot on 2022-12-02T10:49:51-08:00 2 | 3 | app = "codewars-discord-bot" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [experimental] 9 | allowed_public_ports = [] 10 | auto_rollback = true 11 | -------------------------------------------------------------------------------- /templates/message-handler.ts.hbs: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | 3 | // {{name}} 4 | export default async (message: Message) => { 5 | // TODO return true if handled and should stop 6 | // TODO don't forget to include this module in `src/events/messageCreate/handlers/index.ts` 7 | return false; 8 | }; 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Copy this file to .env.development and fill in the values. 2 | # .env.development is gitignored. 3 | 4 | # https://discordjs.guide/preparations/setting-up-a-bot-application.html#your-token 5 | BOT_TOKEN= 6 | # Required to register slash commands 7 | CLIENT_ID= 8 | GUILD_ID= 9 | -------------------------------------------------------------------------------- /src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module "fuzzysearch" { 2 | /** 3 | * Return true only if each character in the needle can be found in the haystack and 4 | * occurs after the preceding character. 5 | * https://www.npmjs.com/package/fuzzysearch 6 | */ 7 | function fuzzysearch(needle: string, haystack: string): boolean; 8 | export = fuzzysearch; 9 | } 10 | -------------------------------------------------------------------------------- /text/warn/conduct.md: -------------------------------------------------------------------------------- 1 | You've violated the following rule(s): 2 | 3 | - Treat everyone with respect. Absolutely no harassment, witch hunting, sexism, racism, or hate speech will be tolerated. 4 | 5 | If you insist on engaging in such behavior, we reserve the right to kick or ban you from the server indefinitely, and your Codewars account (if any) may be restricted. You've been warned. 6 | -------------------------------------------------------------------------------- /text/warn/spam.md: -------------------------------------------------------------------------------- 1 | You've violated the following rule(s): 2 | 3 | - No spam or self-promotion (server invites, advertisements, etc) without permission from @mods. This includes DMing fellow members. 4 | 5 | If you insist on engaging in such behavior, we reserve the right to kick or ban you from the server indefinitely, and your Codewars account (if any) may be restricted. You've been warned. 6 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: superfly/flyctl-actions/setup-flyctl@master 12 | - run: flyctl deploy --remote-only 13 | env: 14 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 15 | -------------------------------------------------------------------------------- /text/warn/content.md: -------------------------------------------------------------------------------- 1 | You've violated the following rule(s): 2 | 3 | - No NSFW or obscene content. This includes text, images, or links featuring nudity, sex, hard violence, or other graphically disturbing content. 4 | 5 | If you insist on engaging in such behavior, we reserve the right to kick or ban you from the server indefinitely, and your Codewars account (if any) may be restricted. You've been warned. 6 | -------------------------------------------------------------------------------- /src/events/ready.ts: -------------------------------------------------------------------------------- 1 | import { Client, ActivityType } from "discord.js"; 2 | 3 | export const makeOnReady = (bot: Client) => async () => { 4 | console.log("ready!"); 5 | bot.user?.setPresence({ 6 | status: "online", 7 | activities: [ 8 | { 9 | name: "Codewars", 10 | type: ActivityType.Playing, 11 | url: "https://www.codewars.com/", 12 | }, 13 | ], 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "allowSyntheticDefaultImports": true, 9 | "importHelpers": true, 10 | "sourceMap": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true 13 | }, 14 | "include": ["src/**/*"] 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/cache@v2 14 | with: 15 | path: ~/.npm 16 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 17 | restore-keys: | 18 | ${{ runner.os }}-node- 19 | - run: npm ci --no-audit 20 | -------------------------------------------------------------------------------- /text/howto/post_link.md: -------------------------------------------------------------------------------- 1 | # How to post links to kata 2 | 3 | - Surround the links to kata with angle brackets: ``. This will prevent Discord from inserting embeds into your message, as they do not carry too much useful information, and are quite annoying. 4 | - Please, do _NOT_ post direct links to kata trainers: make sure you don't have a trailling ~~`/train`~~ in the url. 5 | - Remember to also mention a title of the kata you are linking to. -------------------------------------------------------------------------------- /templates/command.ts.hbs: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, SlashCommandBuilder } from "discord.js"; 2 | 3 | // {{name}} 4 | export const data = async () => 5 | new SlashCommandBuilder() 6 | .setName("{{name}}") 7 | .setDescription("Description for command {{name}}") 8 | .toJSON(); 9 | 10 | export const call = async (interaction: CommandInteraction) => { 11 | // TODO don't forget to include this module in `commands` in `src/commands/index.ts` 12 | await interaction.reply("pong"); 13 | }; 14 | -------------------------------------------------------------------------------- /text/howto/create_thread.md: -------------------------------------------------------------------------------- 1 | # How to create Discord threads 2 | 3 | Please use Discord threads to keep discussions focused, avoid spoilers, and to not introduce unnecessary noise into main channels. Remember to give a meaningful title to your thread, for example _"hobovsky - help with Multiply"_, or just _"hobovsky - Multiply"_, and continue your discussion in the thread. 4 | 5 | See Discord docs if you do not know how to create threads: -------------------------------------------------------------------------------- /text/howto/screenshot.md: -------------------------------------------------------------------------------- 1 | # How to post screenshots 2 | 3 | - Please visit [Screenshot Help](https://screenshot.help) for info how to make screenshots on different platforms, devices, and OSes. 4 | - Please do not post screenshots of code: code snippets shared by you should be easy to copy from Discord, paste into Codewars trainer or local IDE, and run. 5 | - It's OK to post screenshots to show output of tests, problems with editor, etc. 6 | - It's absolutely not OK to post photos of your screen made with your phone instead of screenshots. -------------------------------------------------------------------------------- /src/events/messageCreate/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | 3 | import trainerLink from "./trainer-link"; 4 | 5 | // We can expand this later if we need more than stop/continue. 6 | /// Apply some action to `message`. Return `true` if handled and should stop. 7 | export type MessageAction = (message: Message) => Promise; 8 | 9 | // Reorder this to control precedence. 10 | const handlers: MessageAction[] = [ 11 | // Detects trainer link 12 | trainerLink, 13 | ]; 14 | export default handlers; 15 | -------------------------------------------------------------------------------- /src/events/messageCreate/index.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | 3 | import handlers from "./handlers"; 4 | 5 | // `client` can be accessed from `message.client`. 6 | export const onMessageCreate = async (message: Message) => { 7 | // Never react to bots 8 | if (message.author.bot) return; 9 | 10 | // Message handlers. Stops when a handler returns true. 11 | // Catch any unexpected error. Each handler should handle errors. 12 | try { 13 | for (const action of handlers) { 14 | if (await action(message)) return; 15 | } 16 | } catch (e: any) { 17 | console.error(e.message); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /text/introduce/help-solve.md: -------------------------------------------------------------------------------- 1 | Welcome to #help-solve! For an optimal experience, please include the following information **in #help-solve** (not to me!): 2 | 3 | 1. A link to the Kata you are attempting, e.g. 4 | 2. The language you are attempting the Kata in, e.g. JavaScript 5 | 3. What you have tried before asking for help. We'd love to put in the effort to help you, but only if you first help yourself. 6 | 4. Your current solution in properly formatted code blocks. For example: 7 | 8 | \`\`\`javascript 9 | console.log("Hello World!"); 10 | \`\`\` 11 | 12 | gives 13 | 14 | ```javascript 15 | console.log("Hello World!"); 16 | ``` 17 | 18 | 5. The input, output from your solution and expected output 19 | -------------------------------------------------------------------------------- /text/howto/format_code.md: -------------------------------------------------------------------------------- 1 | # How to use code formatting in Discord messages 2 | 3 | Discord supports Markdown for code blocks and syntax highlighting of various programming languages. 4 | If you want to post code and use syntax highlighting, you need to surround it with three backticks, 5 | with an optional name of your language: 6 | 7 | \`\`\`python 8 | def hello_world(): 9 | print("Hello, world!") 10 | \`\`\` 11 | 12 | When you do this, your code will be neatly formatted: 13 | 14 | ```python 15 | def hello_world(): 16 | print("Hello, world!") 17 | ``` 18 | 19 | You can replace `python` with any other language supported by Discord, or just omit it. 20 | 21 | More info in Discord docs: . -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by `flyctl launch` 2 | FROM debian:bullseye as builder 3 | 4 | ARG NODE_VERSION=16.13.2 5 | 6 | RUN apt-get update; apt install -y curl 7 | RUN curl https://get.volta.sh | bash 8 | ENV VOLTA_HOME /root/.volta 9 | ENV PATH /root/.volta/bin:$PATH 10 | RUN volta install node@${NODE_VERSION} 11 | 12 | ####################################################################### 13 | 14 | RUN mkdir /app 15 | WORKDIR /app 16 | 17 | COPY . . 18 | 19 | RUN npm install 20 | 21 | 22 | # Actual image used to run the bot. 23 | # Node.js and our code is copied into this image. 24 | FROM debian:bullseye 25 | 26 | LABEL fly_launch_runtime="nodejs" 27 | 28 | COPY --from=builder /root/.volta /root/.volta 29 | COPY --from=builder /app /app 30 | 31 | WORKDIR /app 32 | ENV NODE_ENV production 33 | ENV PATH /root/.volta/bin:$PATH 34 | 35 | CMD [ "npm", "run", "start" ] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 kazk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /text/howto/ask_for_help.md: -------------------------------------------------------------------------------- 1 | # How to ask for help 2 | 3 | - Ask your question in an appropriate channel. You can use `#help-solve` for a general help with some kata, or topic-specific channels ( `#python`, or `#algorithms` ) if you need help with an algorithm or a syntax of a language. 4 | - Create a thread to keep the discussion focused on your topic (react with :thread: `:thread:` or use `/howto create_thread` for more info). 5 | - Explain what kata you are trying to solve. Post its title, and a link to its description (react with :link: `:link:` or use `/howto kata_link` for more info). 6 | - Explain what your problem is. Tests do not accept your answers? Solution is timing out? Is there some other problem? 7 | - Post **properly formatted** code of your solution. (react with :hash: `:hash:` or use `/howto format_code` for more info). 8 | - Please recognize good practices and common etiquette when posting screenshots (react with :camera_with_flash: `:camera_with_flash:` or use `/howto screenshot` for more info). 9 | - If the code you posted contains spoilers for the discussed kata, please delete it after discussing your matter and getting all necessary help. -------------------------------------------------------------------------------- /plopfile.mjs: -------------------------------------------------------------------------------- 1 | // See https://github.com/plopjs/plop 2 | // Run `npx plop` to generate a boilerplate. 3 | export default (/** @type {import('plop').NodePlopAPI} */ plop) => { 4 | plop.setGenerator("command", { 5 | description: "new command", 6 | prompts: [ 7 | { 8 | type: "input", 9 | name: "name", 10 | message: "name of the command", 11 | validate: (value) => { 12 | if (/^[a-z]+$/.test(value)) { 13 | return true; 14 | } 15 | return "name should match /^[a-z]+$/"; 16 | }, 17 | }, 18 | ], 19 | actions: [ 20 | { 21 | type: "add", 22 | path: "src/commands/{{name}}.ts", 23 | templateFile: "templates/command.ts.hbs", 24 | }, 25 | ], 26 | }); 27 | 28 | plop.setGenerator("message-handler", { 29 | description: "new message handler", 30 | prompts: [ 31 | { 32 | type: "input", 33 | name: "name", 34 | message: "name of the handler, words separated by hyphen", 35 | validate: (value) => { 36 | if (/^[a-z]+(-[a-z]+)*$/.test(value)) { 37 | return true; 38 | } 39 | return "name should match /^[a-z]+(-[a-z]+)*$/"; 40 | }, 41 | }, 42 | ], 43 | actions: [ 44 | { 45 | type: "add", 46 | path: "src/events/messageCreate/handlers/{{name}}.ts", 47 | templateFile: "templates/message-handler.ts.hbs", 48 | }, 49 | ], 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | 3 | import dotenv from "dotenv"; 4 | import { z } from "zod"; 5 | import { parseEnv } from "znv"; 6 | 7 | // Load config from environment variables. 8 | // Exits if required configs are missing or invalid. 9 | export const fromEnv = () => { 10 | // Use `.env.development` by default. 11 | dotenv.config({ 12 | path: resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`), 13 | }); 14 | try { 15 | return parseEnv(process.env, { 16 | BOT_TOKEN: { 17 | description: 18 | "Your bot token. See https://discordjs.guide/preparations/setting-up-a-bot-application.html#your-token", 19 | schema: z.string().nonempty(), 20 | }, 21 | // Required for slash commands 22 | // TODO Should this be APPLICATION_ID? 23 | CLIENT_ID: { 24 | description: 25 | "Your bot's application id. See https://support-dev.discord.com/hc/en-us/articles/360028717192-Where-can-I-find-my-Application-Team-Server-ID-", 26 | schema: z.string().nonempty(), 27 | }, 28 | GUILD_ID: { 29 | description: 30 | "The server id. See https://support-dev.discord.com/hc/en-us/articles/360028717192-Where-can-I-find-my-Application-Team-Server-ID-", 31 | schema: z.string().nonempty(), 32 | }, 33 | }); 34 | } catch (e) { 35 | if (e instanceof Error) { 36 | console.log(e.message); 37 | } else { 38 | console.error(e); 39 | } 40 | process.exit(1); 41 | } 42 | }; 43 | 44 | export type Config = ReturnType; 45 | -------------------------------------------------------------------------------- /src/commands/userinfo.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; 2 | import { getUser } from "../codewars"; 3 | import { checkBotPlayground, getUsername } from "../common"; 4 | 5 | async function getUserInfo(name: string): Promise { 6 | let user = await getUser(name); 7 | 8 | const rank = user.ranks.overall.name; 9 | const honor = user.honor; 10 | const position = user.leaderboardPosition; 11 | const authored = user.codeChallenges.totalAuthored; 12 | const completed = user.codeChallenges.totalCompleted; 13 | 14 | return `\`\`\` 15 | User: ${name} 16 | Rank: ${rank} 17 | Honor: ${honor}\ 18 | ${position ? `\nPosition: #${position}` : ""}\ 19 | ${authored ? `\nCreated kata: ${authored}` : ""} 20 | Completed kata: ${completed} 21 | \`\`\``; 22 | } 23 | 24 | export const data = async () => 25 | new SlashCommandBuilder() 26 | .setName("userinfo") 27 | .setDescription("Get info about a Codewars user") 28 | .addStringOption((opt) => opt.setName("username").setDescription("The username to look up")) 29 | .addBooleanOption((opt) => opt.setName("ephemeral").setDescription("Hide from others")) 30 | .toJSON(); 31 | 32 | export const call = async (interaction: ChatInputCommandInteraction) => { 33 | const username = getUsername(interaction); 34 | const ephemeral = interaction.options.getBoolean("ephemeral") || false; 35 | checkBotPlayground(ephemeral, interaction); 36 | 37 | const content = await getUserInfo(username); 38 | 39 | await interaction.reply({ 40 | content: content, 41 | ephemeral: ephemeral, 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Client, GatewayIntentBits, Events } from "discord.js"; 2 | 3 | import { fromEnv } from "./config"; 4 | import { updateCommands } from "./commands"; 5 | import { onAutocomplete, onCommand, onMessageCreate, makeOnReady } from "./events"; 6 | 7 | const config = fromEnv(); 8 | 9 | const bot = new Client({ 10 | intents: [ 11 | // Required to work properly. 12 | GatewayIntentBits.Guilds, 13 | GatewayIntentBits.MessageContent, 14 | // For `messageCreate`. 15 | GatewayIntentBits.GuildMessages, 16 | GatewayIntentBits.GuildMessageReactions, 17 | 18 | GatewayIntentBits.DirectMessageReactions, 19 | ], 20 | allowedMentions: { 21 | // Parse roles and users mentions in the context. But not @everyone, nor @here. 22 | parse: ["roles", "users"], 23 | // Mention the author of the message being replied. 24 | repliedUser: true, 25 | }, 26 | }); 27 | 28 | // Add event listeners 29 | bot.once(Events.ClientReady, makeOnReady(bot)); 30 | bot.on(Events.MessageCreate, onMessageCreate); 31 | bot.on(Events.InteractionCreate, onCommand); 32 | bot.on(Events.InteractionCreate, onAutocomplete); 33 | 34 | // Update commands and join 35 | (async () => { 36 | try { 37 | await bot.login(config.BOT_TOKEN); 38 | console.log("Updating application (/) commands"); 39 | await updateCommands(bot, config); 40 | console.log("Updated application (/) commands"); 41 | } catch (error) { 42 | console.error(error); 43 | console.error("Failed to register commands. Aborting."); 44 | // Prevent the bot from running with outdated commands data. 45 | process.exit(1); 46 | } 47 | })(); 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codewars/discord-bot", 3 | "private": true, 4 | "version": "0.2.0", 5 | "description": "The official Discord bot for Codewars", 6 | "author": "kazk", 7 | "license": "MIT", 8 | "engines": { 9 | "node": ">= 16.9.0", 10 | "npm": ">= 7" 11 | }, 12 | "main": "./lib/main.js", 13 | "scripts": { 14 | "start": "node ./lib/main.js", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "clean": "rimraf lib", 18 | "plop": "plop", 19 | "prepare": "husky install && npm run build", 20 | "build": "tsc -p tsconfig.release.json", 21 | "build:watch": "tsc -w -p tsconfig.release.json" 22 | }, 23 | "devDependencies": { 24 | "@types/common-tags": "^1.8.0", 25 | "@types/jest": "^26.0.23", 26 | "@types/node": "^16.11.22", 27 | "@types/ws": "^7.4.4", 28 | "husky": "^6.0.0", 29 | "jest": "^28.0.0", 30 | "plop": "^3.0.5", 31 | "prettier": "^2.3.0", 32 | "pretty-quick": "^3.1.1", 33 | "rimraf": "^3.0.2", 34 | "ts-jest": "^28.0.0", 35 | "ts-node": "^10.0.0", 36 | "typescript": "^5.1.6" 37 | }, 38 | "dependencies": { 39 | "@types/node-fetch": "^2.5.12", 40 | "common-tags": "^1.8.0", 41 | "discord.js": "^14.12.0", 42 | "dotenv": "^16.0.0", 43 | "fuzzysearch": "^1.0.3", 44 | "node-fetch": "^2.6.6", 45 | "table": "^6.8.0", 46 | "tslib": "^2.2.0", 47 | "znv": "^0.3.1", 48 | "zod": "^3.2.0" 49 | }, 50 | "jest": { 51 | "testEnvironment": "node", 52 | "transform": { 53 | "^.+\\.ts$": "ts-jest" 54 | }, 55 | "moduleFileExtensions": [ 56 | "ts", 57 | "js" 58 | ], 59 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.ts$" 60 | }, 61 | "prettier": { 62 | "printWidth": 100 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/events/interactionCreate.ts: -------------------------------------------------------------------------------- 1 | import { CacheType, ChatInputCommandInteraction, Interaction } from "discord.js"; 2 | import { oneLine } from "common-tags"; 3 | 4 | import { commands } from "../commands"; 5 | import { RequestError } from "../codewars"; 6 | 7 | // Listener for (/) commands from humans. 8 | // We may add more listeners like `onButton`. 9 | export const onCommand = async (interaction: Interaction) => { 10 | if (!interaction.isChatInputCommand() || interaction.user.bot) return; 11 | 12 | const { commandName } = interaction; 13 | if (commands.hasOwnProperty(commandName)) { 14 | try { 15 | await commands[commandName].call(interaction); 16 | } catch (e) { 17 | await handleError(e, interaction); 18 | } 19 | } 20 | }; 21 | 22 | async function handleError(err: any, interaction: ChatInputCommandInteraction) { 23 | let msg = ERROR_MESSAGE; 24 | if (err instanceof RequestError) msg = err.message; 25 | else console.error(err); 26 | await interaction.reply({ 27 | content: msg, 28 | // Only show this message to the user who used the command. 29 | ephemeral: true, 30 | }); 31 | } 32 | 33 | const ERROR_MESSAGE = oneLine` 34 | Something went wrong! 35 | If the issue persists, please open an [issue](https://github.com/codewars/discord-bot/issues). 36 | `; 37 | 38 | // Listener for handling autocompletion of command options 39 | export const onAutocomplete = async (interaction: Interaction) => { 40 | if (!interaction.isAutocomplete()) return; 41 | const { commandName } = interaction; 42 | if (!commands.hasOwnProperty(commandName)) return; 43 | const command = commands[commandName]; 44 | if (typeof command.autocomplete !== "function") return; 45 | 46 | try { 47 | const options = await command.autocomplete(interaction); 48 | await interaction.respond(options); 49 | } catch (e) { 50 | console.error(e); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/commands/info.ts: -------------------------------------------------------------------------------- 1 | // A subcommand example 2 | import { ChatInputCommandInteraction, GuildMember, SlashCommandBuilder } from "discord.js"; 3 | import { formatTimeStamp } from "../common"; 4 | 5 | export const data = async () => 6 | new SlashCommandBuilder() 7 | .setName("info") 8 | .setDescription("Replies with info") 9 | .addSubcommand((sub) => 10 | sub 11 | .setName("user") 12 | .setDescription("Info about a user") 13 | .addUserOption((o) => o.setName("user").setDescription("The user")) 14 | ) 15 | .addSubcommand((sub) => sub.setName("server").setDescription("Info about the server")) 16 | .toJSON(); 17 | 18 | export const call = async (interaction: ChatInputCommandInteraction) => { 19 | switch (interaction.options.getSubcommand()) { 20 | case "user": 21 | const userparam = interaction.options.getUser("user"); 22 | const user = userparam || interaction.user; 23 | const member = userparam 24 | ? interaction.guild?.members.cache.get(user.id) || null 25 | : (interaction.member as GuildMember | null); 26 | let msg = `Username: ${user.username}${user.bot ? " *(Bot)*" : ""} 27 | ID: ${user.id} 28 | Created at: ${formatTimeStamp(user.createdTimestamp)} 29 | Global display name: ${user.globalName || "*(not set)*"}`; 30 | if (member) 31 | msg += ` 32 | Server nickname: ${member.nickname || "*(not set)*"} 33 | Display name: ${member.displayName} 34 | Joined server at: ${formatTimeStamp(member.joinedTimestamp)}`; 35 | await interaction.reply(msg); 36 | return; 37 | 38 | case "server": 39 | const guild = await interaction.guild?.fetch(); 40 | if (guild) { 41 | await interaction.reply( 42 | `Server name: ${guild.name} 43 | Total members: ${guild.approximateMemberCount} 44 | Server created at: ${formatTimeStamp(guild.createdTimestamp)}` 45 | ); 46 | } 47 | return; 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/commands/introduce.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | userMention, 5 | hideLinkEmbed, 6 | } from "discord.js"; 7 | import { getTexts } from "../common"; 8 | 9 | const channels = ["help-solve"]; 10 | const channelTexts: Map = getTexts("introduce", channels); 11 | 12 | // introduce 13 | export const data = async () => 14 | new SlashCommandBuilder() 15 | .setName("introduce") 16 | .setDescription("Introduce the user to the specified channel") 17 | .setDefaultPermission(false) 18 | .addUserOption((option) => 19 | option 20 | .setName("user") 21 | .setDescription("User to introduce the specified channel to") 22 | .setRequired(true) 23 | ) 24 | .addChannelOption((option) => 25 | option 26 | .setName("channel") 27 | .setDescription("Channel to introduce the specified user to") 28 | .setRequired(true) 29 | ) 30 | .toJSON(); 31 | 32 | export const call = async (interaction: ChatInputCommandInteraction) => { 33 | const user = interaction.options.getUser("user", true); 34 | const channel = interaction.options.getChannel("channel", true); 35 | const helpText = channelTexts.get(channel.name || ""); 36 | if (!helpText) { 37 | await interaction.reply({ 38 | content: `No help text available for the given channel 39 | 40 | Help text is available for the following channels: 41 | 42 | ${channels.map((channel) => `- **#${channel}**`).join("\n")}`, 43 | ephemeral: true, 44 | }); 45 | return; 46 | } 47 | try { 48 | const dm = await user.createDM(); 49 | await dm.send(helpText); 50 | await interaction.reply(`${userMention(user.id)} please check your DMs`); 51 | } catch (err) { 52 | await interaction.reply( 53 | `${userMention(user.id)} I couldn't DM you. See ${hideLinkEmbed( 54 | `https://github.com/codewars/discord-bot/blob/main/text/introduce/${channel.name}.md` 55 | )} instead.` 56 | ); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RESTPostAPIApplicationCommandsJSONBody, 3 | Routes, 4 | Client, 5 | ApplicationCommandOptionChoiceData, 6 | AutocompleteInteraction, 7 | REST, 8 | ChatInputCommandInteraction, 9 | } from "discord.js"; 10 | 11 | import { Config } from "../config"; 12 | 13 | import * as echo from "./echo"; 14 | import * as info from "./info"; 15 | import * as link from "./link"; 16 | import * as introduce from "./introduce"; 17 | import * as warn from "./warn"; 18 | import * as rankup from "./rankup"; 19 | import * as leaderboard from "./leaderboard"; 20 | import * as userinfo from "./userinfo"; 21 | import * as howto from "./howto"; 22 | 23 | export type Command = { 24 | // Data to send when registering. 25 | data: () => Promise; 26 | // Handler. 27 | call: (interaction: ChatInputCommandInteraction) => Promise; 28 | // Autocompletion handler. 29 | autocomplete?: ( 30 | interaction: AutocompleteInteraction 31 | ) => Promise; 32 | }; 33 | 34 | export const commands: { [k: string]: Command } = { 35 | echo, 36 | info, 37 | link, 38 | introduce, 39 | warn, 40 | rankup, 41 | leaderboard, 42 | userinfo, 43 | howto, 44 | }; 45 | 46 | // The caller is responsible for catching any error thrown 47 | export const updateCommands = async (client: Client, config: Config) => { 48 | const guild = client.guilds.cache.get(config.GUILD_ID); 49 | if (!guild) throw new Error("Failed to get the current guild"); 50 | 51 | const rest = new REST({ version: "10" }).setToken(config.BOT_TOKEN); 52 | const body = await Promise.all(Object.values(commands).map((c) => c.data())); 53 | // Global commands are cached for one hour. 54 | // Guild commands update instantly. 55 | // discord.js recommends guild command when developing and global in production. 56 | // For now, always use guild commands because our bot is only added to our server. 57 | await rest.put(Routes.applicationGuildCommands(config.CLIENT_ID, config.GUILD_ID), { 58 | body, 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /src/events/messageCreate/handlers/trainer-link.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | import { stripIndents, oneLine } from "common-tags"; 3 | 4 | /// Detect direct links to trainer and show adjusted links. 5 | export default async (message: Message) => { 6 | const trainerLinks = message.content.match(PATTERN); 7 | if (!trainerLinks) return false; 8 | 9 | const links = trainerLinks.map((s) => "- <" + s.replace(/\/train\/[a-z]+$/, "") + ">").join("\n"); 10 | const Q = "❓"; 11 | const newMessage = await message.reply(stripIndents` 12 | Please don't link directly to the trainer. Edit your comment and change the url(s) to: 13 | ${links} 14 | React with ${Q} within ${REACTION_SECS}s to get a DM with explanation. 15 | `); 16 | // React to the new message so users can just click it. 17 | const reaction = await newMessage.react(Q); 18 | newMessage 19 | .createReactionCollector({ 20 | filter: (reaction, _user) => reaction.emoji.name === Q, 21 | time: REACTION_SECS * 1000, 22 | }) 23 | .on("collect", async (_reaction, user) => { 24 | if (user.bot) return; 25 | 26 | try { 27 | const dm = await user.createDM(); 28 | await dm.send(INFO); 29 | } catch (err: any) { 30 | // Can error if the user have DM disabled. 31 | console.warn(`failed to DM ${user.tag}: ${err.message || "unknown error"}`); 32 | } 33 | }) 34 | .once("end", async () => { 35 | // Requires `MANAGE_MESSAGE` permission. 36 | try { 37 | await newMessage.edit(newMessage.content.replace(/React with .+$/, "").trimRight()); 38 | await reaction.remove(); 39 | } catch (e: any) { 40 | console.log(`failed to remove reaction: ${e.message || "unknown error"}`); 41 | } 42 | }); 43 | 44 | return true; 45 | }; 46 | 47 | const PATTERN = /https?:\/\/(?:www\.)?codewars.com\/kata\/[0-9a-f]{24}\/train\/[a-z]+/g; 48 | 49 | const INFO = oneLine` 50 | When you post a link to a kata, make sure the link doesn't end with \`/train/{language-id}\`. 51 | Those are links to trainers and will start a training session when followed. This can be annoying 52 | for other users because they can end up with an unwanted kata in their "unfinished" list. 53 | `; 54 | 55 | const REACTION_SECS = 15; 56 | -------------------------------------------------------------------------------- /src/commands/warn.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | userMention, 5 | hideLinkEmbed, 6 | } from "discord.js"; 7 | import { getTexts } from "../common"; 8 | 9 | const reasons = [ 10 | { 11 | name: "conduct", 12 | description: "Poor conduct: harassment, witch hunting, sexism, etc.", 13 | }, 14 | { 15 | name: "content", 16 | description: "Offensive / NSFW content: nudity, sex, violence, etc.", 17 | }, 18 | { 19 | name: "spam", 20 | description: 21 | "Spam / phishing: unauthorized server invites, advertisements, malicious links etc.", 22 | }, 23 | ]; 24 | 25 | const warnTexts: Map = getTexts( 26 | "warn", 27 | reasons.map((reason) => reason.name) 28 | ); 29 | 30 | // warn 31 | export const data = async () => 32 | new SlashCommandBuilder() 33 | .setName("warn") 34 | .setDescription("Warn the user for violation of server rules") 35 | .setDefaultPermission(false) 36 | .addUserOption((option) => 37 | option.setName("user").setDescription("The user who violated server rules").setRequired(true) 38 | ) 39 | .addStringOption((option) => 40 | option 41 | .setName("reason") 42 | .setDescription("The reason the specified user violated server rules") 43 | .setRequired(true) 44 | .addChoices(...reasons.map((reason) => ({ name: reason.description, value: reason.name }))) 45 | ) 46 | .toJSON(); 47 | 48 | export const call = async (interaction: ChatInputCommandInteraction) => { 49 | const user = interaction.options.getUser("user", true); 50 | const reason = interaction.options.getString("reason", true); 51 | const reply = warnTexts.get(reason); 52 | if (!reply) { 53 | await interaction.reply({ 54 | content: `Could not get the text for reason "${reason}" 55 | 56 | Text is available for the following reasons: 57 | 58 | ${reasons.map((reason) => `- ${reason.name}`).join("\n")}`, 59 | ephemeral: true, 60 | }); 61 | return; 62 | } 63 | try { 64 | const dm = await user.createDM(); 65 | await dm.send(reply); 66 | await interaction.reply( 67 | `${userMention(user.id)} You've violated server rules, please check your DMs now` 68 | ); 69 | } catch (err) { 70 | await interaction.reply( 71 | `${userMention(user.id)} You've violated server rules, see ${hideLinkEmbed( 72 | `https://github.com/codewars/discord-bot/blob/main/text/warn/${reason}.md` 73 | )} for details.` 74 | ); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /src/commands/echo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | blockQuote, 5 | bold, 6 | codeBlock, 7 | inlineCode, 8 | italic, 9 | quote, 10 | spoiler, 11 | strikethrough, 12 | underscore, 13 | } from "discord.js"; 14 | import { checkBotPlayground } from "../common"; 15 | 16 | const formats: { [k: string]: (s: string) => string } = { 17 | blockQuote, 18 | bold, 19 | codeBlock, 20 | inlineCode, 21 | italic, 22 | quote, 23 | spoiler, 24 | strikethrough, 25 | underscore, 26 | }; 27 | 28 | export const data = async () => 29 | new SlashCommandBuilder() 30 | .setName("echo") 31 | .setDescription("Replies with your input, optionally formatted") 32 | .addStringOption((option) => 33 | option.setName("input").setDescription("The input to echo back").setRequired(true) 34 | ) 35 | .addStringOption((option) => 36 | option 37 | .setName("format") 38 | .setDescription("The format to use") 39 | .addChoices(...Object.keys(formats).map((k) => ({ name: k, value: k }))) 40 | ) 41 | .toJSON(); 42 | 43 | export const call = async (interaction: ChatInputCommandInteraction) => { 44 | checkBotPlayground(false, interaction); 45 | const input = interaction.options.getString("input", true); 46 | const format = interaction.options.getString("format"); 47 | const msg = format && formats.hasOwnProperty(format) ? formats[format](input) : input; 48 | await interaction.reply(msg); 49 | }; 50 | 51 | /* 52 | // It's annoying having to repeat options name/type in `data` and `call`. 53 | // Maybe it's possible to define `options` in `data` with `zod`? 54 | // Registering should work as long as we can produce an object matching 55 | // `RESTPostAPIApplicationCommandsJSONBody`. 56 | const data = new SlashCommandBuilder() 57 | .setName("echo") 58 | .setDescription("Replies with your input") 59 | .addStringOption((option) => 60 | option.setName("input").setDescription("The input to echo back").setRequired(true) 61 | ) 62 | .toJSON(); 63 | // is equivalent to 64 | const data = { 65 | name: "echo", 66 | description: "Replies with your input", 67 | options: [ 68 | { 69 | // ApplicationCommandOptionType.String 70 | type: 3, 71 | name: "input", 72 | description: "The input to echo back", 73 | required: true, 74 | }, 75 | ], 76 | }; 77 | // will be nice to use zod to define options, so we can extract in `call`. 78 | const Options = z.preprocess( 79 | // preprocess `interaction.options.data` array into an object 80 | preprocessor, 81 | // schema 82 | z.object({ 83 | input: z.string().nonempty().describe("The input to echo back"), 84 | }) 85 | ); 86 | const data = { 87 | name: "echo", 88 | description: "Replies with your input", 89 | options: toDiscordOptions(Options), 90 | }; 91 | // so we can get typed options 92 | // const { input } = Options.parse(interaction.options.data); 93 | */ 94 | -------------------------------------------------------------------------------- /src/commands/link.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatInputCommandInteraction, 3 | User, 4 | SlashCommandBuilder, 5 | hyperlink, 6 | hideLinkEmbed, 7 | userMention, 8 | italic, 9 | inlineCode, 10 | } from "discord.js"; 11 | 12 | const LINKS: { [k: string]: { description: string; url: string } } = { 13 | docs: { 14 | description: "The Codewars Docs", 15 | url: "https://docs.codewars.com/", 16 | }, 17 | howto: { 18 | description: "How to train on a Codewars kata | The Codewars Docs", 19 | url: "https://docs.codewars.com/training/training-example", 20 | }, 21 | honor: { 22 | description: "Honor | The Codewars Docs", 23 | url: "https://docs.codewars.com/gamification/honor", 24 | }, 25 | kata: { 26 | description: "Kata | Codewars", 27 | url: "https://www.codewars.com/kata", 28 | }, 29 | authoring: { 30 | description: "Authoring Content | The Codewars Docs", 31 | url: "https://docs.codewars.com/authoring", 32 | }, 33 | rank: { 34 | description: "Reviewing a Kata", 35 | url: "https://docs.codewars.com/curation/kata", 36 | }, 37 | troubleshooting: { 38 | description: "Troubleshooting Your Solution | The Codewars Docs", 39 | url: "https://docs.codewars.com/training/troubleshooting", 40 | }, 41 | github: { 42 | description: "Codewars on GitHub", 43 | url: "https://github.com/codewars/", 44 | }, 45 | clans: { 46 | description: "Clans | The Codewars Docs", 47 | url: "https://docs.codewars.com/community/following/#clans", 48 | }, 49 | vim: { 50 | description: "Editors | The Codewars Docs", 51 | url: "https://docs.codewars.com/references/kata-trainer/#editors", 52 | }, 53 | screenshot: { 54 | description: "Screenshot Help", 55 | "url": "https://screenshot.help/" 56 | } 57 | }; 58 | 59 | // link 60 | export const data = async () => 61 | new SlashCommandBuilder() 62 | .setName("link") 63 | .setDescription("Link to a given topic") 64 | .addStringOption((option) => 65 | option 66 | .setName("topic") 67 | .setDescription("The topic to link to") 68 | .setRequired(true) 69 | .addChoices(...Object.keys(LINKS).map((k) => ({ name: LINKS[k].description, value: k }))) 70 | ) 71 | .addUserOption((option) => 72 | option.setName("target").setDescription("Direct the specified user to the given link") 73 | ) 74 | .toJSON(); 75 | 76 | export const call = async (interaction: ChatInputCommandInteraction) => { 77 | const topic = interaction.options.getString("topic", true); 78 | const target = interaction.options.getUser("target"); 79 | const linkInfo = LINKS[topic]; 80 | const message = `See ${hyperlink(linkInfo.description, hideLinkEmbed(linkInfo.url))}`; 81 | await interaction.reply({ 82 | content: maybeMention(message, target), 83 | ephemeral: !target, 84 | }); 85 | }; 86 | 87 | const maybeMention = (msg: string, target: User | null) => 88 | target ? `${userMention(target.id)} ${msg}` : `${msg}\n\n${italic(NOTE)}`; 89 | 90 | const NOTE = `Note: to use this command in response to a user's query, specify the ${inlineCode( 91 | "target" 92 | )} option when invoking the command.`; 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Codewars Discord Bot 2 | 3 | [![Discord chat](https://img.shields.io/discord/846624424199061524.svg?logo=discord&style=flat)](https://discord.gg/mSwJWRvkHA) 4 | [![CI](https://github.com/codewars/discord-bot/workflows/CI/badge.svg)](https://github.com/codewars/discord-bot/actions?query=workflow%3ACI) 5 | [![License MIT](https://img.shields.io/github/license/codewars/discord-bot)](./LICENSE) 6 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat)](https://github.com/prettier/prettier) 7 | 8 | The official Discord bot for Codewars. 9 | 10 | ## Project Status 11 | 12 | Early stage. Expect breaking changes. 13 | 14 | Feedback is appreciated (Discord or GitHub issues/discussions). 15 | 16 | ## Configuration 17 | 18 | The following environment variables are required: 19 | 20 | - `BOT_TOKEN`: The token used to log in. 21 | - `CLIENT_ID`: The ID of the application associated with the bot. 22 | - `GUILD_ID`: The ID of the server where slash commands should be registered. 23 | 24 | Use `.env.development` (gitignored) to configure these variables. 25 | 26 | ## Development Setup 27 | 28 | > NOTE: Please discuss with us first before adding new features to avoid wasting your time. 29 | 30 | Before working on this repo, you should already have [set up a bot account](https://discordjs.guide/preparations/setting-up-a-bot-application.html#creating-your-bot) and [added it to your development server](https://discordjs.guide/preparations/adding-your-bot-to-servers.html), with at least the following permissions: 31 | 32 | - `applications.commands`: Enables the use of slash commands 33 | - `bot`: Enables your application to join the server as a bot 34 | - `SEND_MESSAGES`: Enables your bot to send messages to channels 35 | - `MANAGE_MESSAGES`: Enables your bot to edit server messages and reactions 36 | 37 | You also need to enable the `MESSAGE CONTENT INTENT` for your bot. 38 | 39 | In order to mimic the Codewars Discord server in your development server, you may also wish to add appropriate roles such as `@admin`, `@mods` and `@power-users`, as well as common channels such as `#help-solve` and `#bot-playground`. 40 | 41 | ### Making Changes 42 | 43 | 1. Fork this repo 44 | 1. Clone the fork to your local development environment, assuming `GITHUB_USERNAME` is set to your GitHub username: 45 | 46 | ```bash 47 | $ git clone git@github.com:"$GITHUB_USERNAME"/discord-bot.git 48 | ``` 49 | 50 | 1. Make this project your working directory 51 | 1. Install dependencies and compile TypeScript 52 | 53 | ```bash 54 | $ npm install 55 | ``` 56 | 57 | 1. Start TypeScript compiler process to recompile on change: 58 | 59 | ```bash 60 | $ npm run build:watch 61 | ``` 62 | 63 | 1. In a new terminal session, copy `.env.example` to `.env.development`: 64 | 65 | ```bash 66 | $ cp .env.example .env.development 67 | ``` 68 | 69 | 1. In `.env.development`: 70 | - Set `BOT_TOKEN` to your [bot token](https://discordjs.guide/preparations/setting-up-a-bot-application.html#your-token) 71 | - Set `CLIENT_ID` and `GUILD_ID` to your [application ID and server ID](https://support-dev.discord.com/hc/en-us/articles/360028717192-Where-can-I-find-my-Application-Team-Server-ID-), respectively 72 | 1. Start the bot: 73 | 74 | ```bash 75 | $ npm start 76 | ``` 77 | 78 | After confirming that the bot works as expected, make changes to the local copy of your fork as appropriate and test your changes by restarting the bot. 79 | 80 | ### Adding a new command 81 | 82 | Run `npx plop command` to generate boilerplate. You will be asked to enter the name of the command (lowercase English letters only) which should be a verb and select an associated category. 83 | 84 | If your command belongs to a category that does not exist yet, stop the command generation by pressing `Ctrl-C`, then modify `plopfile.mjs` as appropriate to add your category and re-run `npx plop command`. 85 | 86 | ### Adding a new message handler 87 | 88 | Run `npx plop message-handler` to generate boilerplate. 89 | 90 | ### Code Style 91 | 92 | [Prettier](https://prettier.io/) is used to ensure consistent style. We use the defaults except for `printWidth: 100` because `80` is often too narrow with types. 93 | 94 | `pre-commit` hook to format staged changes is installed automatically when you run `npm install`, so you don't need to do anything. However, it's recommended to [configure your editor](https://prettier.io/docs/en/editors.html) to format on save, and forget about formatting. 95 | 96 | ## License 97 | 98 | [MIT](./LICENSE) 99 | -------------------------------------------------------------------------------- /src/commands/leaderboard.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; 2 | import { 3 | RequestError, 4 | getLeaderboard, 5 | Language, 6 | LeaderboardPosition, 7 | getLeaderboardForUser, 8 | } from "../codewars"; 9 | import { table, TableUserConfig } from "table"; 10 | import { findLanguage, checkBotPlayground, getUsername } from "../common"; 11 | export { languageAutocomplete as autocomplete } from "../common"; 12 | 13 | const tableConfig = (highlight: number | null): TableUserConfig => { 14 | let drawHorizontalLine = 15 | highlight === null 16 | ? (lineIndex: number, rowCount: number) => lineIndex < 2 || lineIndex === rowCount 17 | : (lineIndex: number, rowCount: number) => 18 | [0, 1, highlight + 1, highlight + 2, rowCount].includes(lineIndex); 19 | return { 20 | drawHorizontalLine, 21 | columns: [ 22 | { alignment: "right" }, 23 | { alignment: "left" }, 24 | { alignment: "right" }, 25 | { alignment: "left" }, 26 | ], 27 | }; 28 | }; 29 | 30 | // leaderboard 31 | export const data = async () => 32 | new SlashCommandBuilder() 33 | .setName("leaderboard") 34 | .setDescription("Show the language score leaderboards") 35 | .addSubcommand((sub) => 36 | sub 37 | .setName("top") 38 | .setDescription("Show the language score top 500 leaderboards") 39 | .addStringOption((option) => 40 | option 41 | .setName("language") 42 | .setDescription("The programming language to show leaderboard for") 43 | .setAutocomplete(true) 44 | ) 45 | .addIntegerOption((option) => 46 | option 47 | .setName("startposition") 48 | .setDescription("The top shown position (default 1)") 49 | .setMinValue(1) 50 | ) 51 | .addIntegerOption((option) => 52 | option 53 | .setName("limit") 54 | .setDescription("The number of shown users (default 10, range 1 - 25)") 55 | .setMinValue(1) 56 | // cut off at 25 because of Discord message length limit, 31 would fit 57 | .setMaxValue(25) 58 | ) 59 | .addBooleanOption((option) => 60 | option.setName("ephemeral").setDescription("Don't show leaderboard to others") 61 | ) 62 | ) 63 | .addSubcommand((sub) => 64 | sub 65 | .setName("user") 66 | .setDescription("Search for a user on the language score leaderboard") 67 | .addStringOption((option) => 68 | option.setName("username").setDescription("The Codewars username to search for") 69 | ) 70 | .addStringOption((option) => 71 | option 72 | .setName("language") 73 | .setDescription("The programming language to show leaderboard for") 74 | .setAutocomplete(true) 75 | ) 76 | .addBooleanOption((option) => 77 | option.setName("ephemeral").setDescription("Don't show leaderboard to others") 78 | ) 79 | ) 80 | .toJSON(); 81 | 82 | function format(lang: Language | null, leaderboard: LeaderboardPosition[], user: string | null) { 83 | const data = [ 84 | ["#", "username", "score", "rank"], 85 | ...leaderboard.map((u) => [u.position, u.username, u.score, getRank(u.rank)]), 86 | ]; 87 | return ( 88 | `:trophy: **${lang?.name ?? "Overall"} leaderboard** :medal:\n` + 89 | "```\n" + 90 | table(data, tableConfig(user ? leaderboard.findIndex((u) => user === u.username) : null)) + 91 | "\n```" 92 | ); 93 | } 94 | function getRank(rank: number) { 95 | if (rank > 0) return rank + " dan"; 96 | return -rank + " kyu"; 97 | } 98 | 99 | export const call = async (interaction: ChatInputCommandInteraction) => { 100 | const ephemeral = interaction.options.getBoolean("ephemeral") || false; 101 | checkBotPlayground(ephemeral, interaction); 102 | const language = await findLanguage(interaction.options.getString("language")); 103 | let leaderboard: LeaderboardPosition[] = []; 104 | let user = null; 105 | switch (interaction.options.getSubcommand()) { 106 | case "top": 107 | const startPosition = Math.max(interaction.options.getInteger("startposition") ?? 1, 1); 108 | const limit = Math.min(Math.max(interaction.options.getInteger("limit") ?? 10, 1), 25); 109 | leaderboard = await getLeaderboard(language?.id ?? null, startPosition, limit); 110 | if (leaderboard.length == 0) 111 | throw new RequestError(`No leaderboard entries found for position ${startPosition}.`); 112 | break; 113 | 114 | case "user": 115 | user = getUsername(interaction); 116 | leaderboard = await getLeaderboardForUser(language?.id ?? null, user); 117 | if (leaderboard.length == 0) { 118 | await interaction.reply({ 119 | content: `${user} was not found on the ${ 120 | language?.name ?? "Overall" 121 | } top 500 leaderboard`, 122 | ephemeral, 123 | }); 124 | return; 125 | } 126 | } 127 | 128 | await interaction.reply({ 129 | content: format(language, leaderboard, user), 130 | ephemeral, 131 | }); 132 | }; 133 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import * as path from "path"; 3 | import { 4 | AutocompleteInteraction, 5 | ChatInputCommandInteraction, 6 | CommandInteraction, 7 | CommandInteractionOption, 8 | GuildMember, 9 | TextChannel, 10 | } from "discord.js"; 11 | import { RequestError, getLanguages, Language } from "./codewars"; 12 | import fuzzysearch from "fuzzysearch"; 13 | 14 | const textPath = path.join(__dirname, "../text"); 15 | 16 | export const getTexts = (commandName: string, values: string[]): Map => { 17 | const commandPath = path.join(textPath, commandName); 18 | const texts: Map = new Map(); 19 | try { 20 | for (const value of values) 21 | texts.set(value, readFileSync(path.join(commandPath, `${value}.md`)).toString()); 22 | } catch (err: any) { 23 | console.error(`failed to read texts under ${commandPath}: ${err.message || "unknown error"}`); 24 | process.exit(1); 25 | } 26 | return texts; 27 | }; 28 | 29 | /** 30 | * Attemps to parse a username from the 'username' option. If no value is present the display name of the user sending the interaction is taken. 31 | * @param interaction the CommandInteraction to get the values from 32 | * @returns username 33 | * @throws RequestError if the username could not be fetched 34 | */ 35 | export const getUsername = (interaction: ChatInputCommandInteraction): string => { 36 | let username = interaction.options.getString("username"); 37 | if (!username) { 38 | const member = interaction.member; 39 | const displayName = member instanceof GuildMember ? member.displayName : member?.nick; 40 | if (!displayName) throw new RequestError("Failed to fetch the name of the current user"); 41 | username = displayName; 42 | } 43 | return username; 44 | }; 45 | 46 | /** 47 | * Language option autocomplete interaction. Export as `autocomplete` to activate. 48 | * @param interaction the AutocompleteInteraction to check 49 | * @returns List of fuzzy matching languages, max 25 50 | */ 51 | export const languageAutocomplete = async (interaction: AutocompleteInteraction) => { 52 | const focused = getFocused(interaction.options.data); 53 | // The following shouldn't happen since "language" is the only option with autocompletion, but 54 | // this can be used to detect the focused option if we have multiple autocomplete options. 55 | if (focused?.name !== "language") return []; 56 | 57 | const typed = interaction.options.getString("language"); 58 | // We can't show all options because we have more than 25. 59 | // Discord shows "no option match your search" when returning an empty array. 60 | if (!typed) return []; 61 | 62 | const languages = await getLanguages(); 63 | const ignoreCase = typed.toLowerCase() === typed; 64 | const filtered = languages 65 | .filter( 66 | ({ id, name }) => 67 | fuzzysearch(typed, id) || fuzzysearch(typed, ignoreCase ? name.toLowerCase() : name) 68 | ) 69 | .map(({ id, name }) => ({ name: name, value: id })); 70 | // Make sure the response is 25 items or less. 71 | return filtered.slice(0, 25); 72 | }; 73 | 74 | function getFocused( 75 | data: readonly CommandInteractionOption[] | undefined 76 | ): CommandInteractionOption | undefined { 77 | return ( 78 | data?.find((opt) => opt.focused) ?? data?.map((opt) => getFocused(opt.options)).find((o) => o) 79 | ); 80 | } 81 | 82 | /** 83 | * Attempts to parse a language matching by id or name to the given language option string or 84 | * `null` if not given (= overall). Throws {@link RequestError} if no matching language was found. 85 | * @param language - the language option to parse 86 | * @returns Language or null 87 | * @throws RequestError 88 | */ 89 | export const findLanguage = async (language: string | null): Promise => { 90 | if (language) { 91 | const languages = await getLanguages(); 92 | // Discord started sending the `name` of autocompleted options. 93 | // Work around by finding the language id by name. 94 | const found = languages.find((x) => x.id === language || x.name === language); 95 | if (!found) throw new RequestError(`${language} is not a valid language id or name`); 96 | return found; 97 | } 98 | return null; 99 | }; 100 | 101 | const REDIRECT: string = "This command is only available in channel **#bot-playground**"; 102 | 103 | /** 104 | * Restrict command output to #bot-playground unless ephemeral is set. 105 | * @throws RequestError 106 | */ 107 | export const checkBotPlayground = (ephemeral: Boolean, interaction: CommandInteraction) => { 108 | if (!ephemeral && (interaction.channel as TextChannel).name !== "bot-playground") 109 | throw new RequestError(REDIRECT); 110 | }; 111 | 112 | /** 113 | * Format the given timestamp as discord timestamp in full short date/time format or "unknown" if null. 114 | * @param timestamp the unix timestamp in milliseconds 115 | * @returns formatted date string 116 | * @see https://discord.com/developers/docs/reference#message-formatting-timestamp-styles 117 | */ 118 | export const formatTimeStamp = (timestamp: number | null): string => { 119 | return timestamp ? `` : "unknown"; 120 | }; 121 | -------------------------------------------------------------------------------- /src/codewars.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import fetch from "node-fetch"; 3 | 4 | // Model of the response using zod, for validation 5 | 6 | const RankInfo = z.object({ 7 | rank: z.number(), 8 | name: z.string(), 9 | color: z.string(), 10 | score: z.number(), 11 | }); 12 | 13 | const UserInfo = z.object({ 14 | username: z.string(), 15 | name: z.nullable(z.string()), 16 | honor: z.number(), 17 | clan: z.nullable(z.string()), 18 | leaderboardPosition: z.nullable(z.number()), 19 | skills: z.nullable(z.array(z.string())), 20 | ranks: z.object({ 21 | overall: RankInfo, 22 | languages: z.record(RankInfo), 23 | }), 24 | codeChallenges: z.object({ 25 | totalAuthored: z.number(), 26 | totalCompleted: z.number(), 27 | }), 28 | }); 29 | 30 | const Language = z.object({ 31 | id: z.string(), 32 | name: z.string(), 33 | }); 34 | 35 | const LeaderboardPosition = z.object({ 36 | position: z.number(), 37 | username: z.string(), 38 | score: z.number(), 39 | rank: z.number(), 40 | }); 41 | 42 | type RankInfo = z.infer; 43 | export type UserInfo = z.infer; 44 | export type Language = z.infer; 45 | export type LeaderboardPosition = z.infer; 46 | 47 | /** Error class indicating an invalid request. */ 48 | export class RequestError extends Error {} 49 | export class UserNotFoundError extends RequestError { 50 | constructor(user: string) { 51 | super(`Could not find user: ${user}`); 52 | } 53 | } 54 | 55 | /** 56 | * Get user info. 57 | * 58 | * @param user - Username or id 59 | * @returns User info 60 | */ 61 | export async function getUser(user: string): Promise { 62 | const response = await fetch("https://www.codewars.com/api/v1/users/" + user); 63 | if (response.status === 404) throw new UserNotFoundError(user); 64 | return UserInfo.parse(await response.json()); 65 | } 66 | 67 | /** 68 | * Get user's language or overall score. 69 | * 70 | * @param user - Username or id 71 | * @param lang - Optional language to get the score. 72 | * Overall score is returned if unspecified. 73 | * @returns Language or overall score 74 | */ 75 | export async function getScore(user: string, lang: string | null): Promise { 76 | const info = await getUser(user); 77 | return (lang ? info.ranks.languages[lang] : info.ranks.overall)?.score ?? 0; 78 | } 79 | 80 | let languages: Language[] | null = null; 81 | /** 82 | * Get the list of supported languages. 83 | * The result is stored, so this only requests once. 84 | * The bot restarts at least once a day, so this should be fine for now. 85 | * @returns Supported languages 86 | */ 87 | export const getLanguages: () => Promise = async () => { 88 | if (!languages) { 89 | const response = await fetch("https://www.codewars.com/api/v1/languages"); 90 | languages = z.object({ data: z.array(Language) }).parse(await response.json()).data; 91 | } 92 | return languages; 93 | }; 94 | 95 | const USERS_PER_PAGE = 50; 96 | 97 | /** 98 | * Get a leaderboard for rank score. 99 | * 100 | * @param lang - Optional language to get the leaderboard. 101 | * Overall leaderboard is returned if unspecified. 102 | * @param startPosition - The first shown user position of the result. 103 | Will at least 1 be taken as 1. 104 | * @param limit - Number of positions to fetch 105 | * @returns Language or overall leaderboard 106 | */ 107 | export async function getLeaderboard( 108 | lang: string | null, 109 | startPosition: number, 110 | limit: number 111 | ): Promise { 112 | lang = lang ?? "overall"; 113 | startPosition = Math.max(startPosition, 1); 114 | let startPage = Math.ceil(startPosition / USERS_PER_PAGE); 115 | let endPage = Math.ceil((startPosition + limit - 1) / USERS_PER_PAGE); 116 | let result: LeaderboardPosition[] = []; 117 | for (let p = startPage; p <= endPage; ++p) { 118 | const url = "https://www.codewars.com/api/v1/leaders/ranks/" + lang + "?page=" + p; 119 | const response = await fetch(url); 120 | let data = z.object({ data: z.array(LeaderboardPosition) }).parse(await response.json()).data; 121 | if (data.length == 0) break; 122 | result.push(...data); 123 | } 124 | let start = (startPosition - 1) % USERS_PER_PAGE; 125 | return result.slice(start, start + limit); 126 | } 127 | /** 128 | * Get a leaderboard for rank score searching for a given user. 129 | * 130 | * @param lang - Optional language to get the leaderboard. 131 | * Overall leaderboard is returned if unspecified. 132 | * @param user - The name of the Codewars user to search for. 133 | * @returns Language or overall leaderboard 134 | */ 135 | export async function getLeaderboardForUser( 136 | lang: string | null, 137 | user: string 138 | ): Promise { 139 | lang = lang ?? "overall"; 140 | const url = "https://www.codewars.com/api/v1/leaders/ranks/" + lang + "?user=" + user; 141 | const response = await fetch(url); 142 | if (response.status === 404) throw new UserNotFoundError(user); 143 | return z.object({ data: z.array(LeaderboardPosition) }).parse(await response.json()).data; 144 | } 145 | 146 | -------------------------------------------------------------------------------- /src/commands/howto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | SlashCommandSubcommandBuilder, 5 | userMention, 6 | hyperlink, 7 | hideLinkEmbed, 8 | User, 9 | GuildMember, 10 | APIInteractionGuildMember, 11 | } from "discord.js"; 12 | import { getTexts } from "../common"; 13 | 14 | // howto 15 | const commands: HowtoCommand[] = [ 16 | { 17 | name: "ask_for_help", 18 | description: "How to ask for help in a way others will want to help you", 19 | reactions: [ 20 | { emoji: "🧵", command: "create_thread" }, 21 | { emoji: "🔗", command: "post_link" }, 22 | { emoji: "#️⃣", command: "format_code" }, 23 | { emoji: "📸", command: "screenshot" } 24 | ], 25 | }, 26 | { 27 | name: "format_code", 28 | description: "How to use code formatting in Discord messages", 29 | reactions: [], 30 | }, 31 | { 32 | name: "create_thread", 33 | description: "How to create discord threads", 34 | reactions: [], 35 | }, 36 | { 37 | name: "post_link", 38 | description: "How to post links to kata", 39 | reactions: [], 40 | }, 41 | { 42 | name: "screenshot", 43 | description: "How to post screenshots", 44 | reactions: [], 45 | }, 46 | ]; 47 | 48 | const howtoTexts: Map = getTexts( 49 | "howto", 50 | commands.map((c) => c.name) 51 | ); 52 | 53 | export const data = async () => { 54 | const addTargetUserParam = (scb: SlashCommandSubcommandBuilder) => 55 | scb.addUserOption((b) => 56 | b.setName("user").setDescription("User to send the message to").setRequired(false) 57 | ); 58 | 59 | let rootCommandBuilder = new SlashCommandBuilder() 60 | .setName("howto") 61 | .setDescription("HOWTOs and tutorials"); 62 | 63 | for (let subcommand of commands) { 64 | rootCommandBuilder.addSubcommand((scb) => 65 | addTargetUserParam(scb.setName(subcommand.name).setDescription(subcommand.description)) 66 | ); 67 | } 68 | return rootCommandBuilder.toJSON(); 69 | }; 70 | 71 | const allowedRoles = new Set(["admin", "mods", "mods+", "power-users"]); 72 | 73 | const hasSufficientPrivilege = (member: GuildMember | APIInteractionGuildMember | null) => { 74 | let guildMember: GuildMember = member as GuildMember; 75 | return guildMember && guildMember.roles.cache.some((role) => allowedRoles.has(role.name)); 76 | }; 77 | 78 | export const call = async (interaction: ChatInputCommandInteraction) => { 79 | let interactionReply = async (content: string, ephemeral: boolean = true) => { 80 | await interaction.reply({ content, ephemeral }); 81 | }; 82 | 83 | let invokingUser = interaction.user; 84 | let targetUser = interaction.options.getUser("user", false) ?? invokingUser; 85 | 86 | if (targetUser.bot) { 87 | await interactionReply( 88 | `${userMention(invokingUser.id)}, you cannot use this command on a bot.` 89 | ); 90 | return; 91 | } 92 | 93 | let selfTarget = targetUser.id === invokingUser.id; 94 | if (selfTarget || hasSufficientPrivilege(interaction.member)) { 95 | let subCommand = interaction.options.getSubcommand(); 96 | let dmReply = commands.find((c) => c.name == subCommand); 97 | 98 | if (dmReply) { 99 | try { 100 | await Promise.all([ 101 | postHowtoDm(dmReply, targetUser), 102 | interactionReply(`${userMention(targetUser.id)} please check your DMs`, selfTarget) 103 | ]); 104 | } catch (reason) { 105 | let url = `https://github.com/codewars/discord-bot/blob/main/text/howto/${subCommand}.md`; 106 | await interactionReply( 107 | `${userMention(targetUser.id)} I couldn't DM you. See ${hyperlink( 108 | dmReply.description, 109 | hideLinkEmbed(url) 110 | )} instead.`, 111 | selfTarget 112 | ); 113 | } 114 | } else { 115 | await interactionReply(`Unknown command: \`${subCommand}\``); 116 | } 117 | } else { 118 | await interactionReply( 119 | `${userMention(invokingUser.id)}, you are not privileged to use this command.` 120 | ); 121 | } 122 | }; 123 | 124 | const postHowtoDm = async (command: HowtoCommand, targetUser: User) => { 125 | let body = howtoTexts.get(command.name); 126 | if (!body) { 127 | return; 128 | } 129 | let message = await targetUser.send(body); 130 | 131 | await Promise.all(command.reactions.map(r => message.react(r.emoji))); 132 | 133 | // Do not set up a collector if there are no reactions 134 | if (!command.reactions.length) return; 135 | 136 | const collector = message.createReactionCollector({ time: 30*1000, filter: (_, reactor) => !reactor.bot }); 137 | collector 138 | .on("collect", (reaction, reactor) => { 139 | if (reactor.bot || reactor.id != targetUser.id) return; 140 | 141 | let emoji = reaction.emoji.name; 142 | let commandName = command.reactions.find((r) => r.emoji == emoji)?.command; 143 | let reactionCommand = commands.find((c) => c.name == commandName); 144 | if (reactionCommand) { 145 | postHowtoDm(reactionCommand, targetUser); 146 | } 147 | }) 148 | .once("end", async () => { 149 | // Clean up info on reactions when the collector expires. 150 | // Requires `MANAGE_MESSAGE` permission. 151 | try { 152 | await message.edit(message.content.replace(/react with .+? or /g, "")); 153 | 154 | // On DMs, an attempt to remove another user's reaction crashes so 155 | // the bot needs to take care to remove only its own reactions. 156 | await Promise.all(message.reactions.cache.map(r => r.users.remove(message.author))); 157 | } catch (e: any) { 158 | console.error(`failed to remove reaction: ${e.message || "unknown error"}`); 159 | } 160 | }); 161 | }; 162 | 163 | type Reaction = { 164 | emoji: string; 165 | command: string; 166 | }; 167 | 168 | type HowtoCommand = { 169 | name: string; 170 | description: string; 171 | reactions: Reaction[]; 172 | }; 173 | -------------------------------------------------------------------------------- /src/commands/rankup.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; 2 | import { getScore, Language } from "../codewars"; 3 | import { checkBotPlayground, findLanguage, getUsername } from "../common"; 4 | export { languageAutocomplete as autocomplete } from "../common"; 5 | 6 | const LEAST = "least"; 7 | const EACH = "each"; 8 | const SPREAD = "spread"; 9 | type Mode = typeof LEAST | typeof EACH | typeof SPREAD; 10 | 11 | const modes = [ 12 | { 13 | name: LEAST, 14 | description: "Complete the fewest possible Kata", 15 | }, 16 | { 17 | name: EACH, 18 | description: "Complete only Kata of the same rank", 19 | }, 20 | { 21 | name: SPREAD, 22 | description: "Complete a balanced range of Kata (default)", 23 | }, 24 | ]; 25 | 26 | const DEFAULTMODE = SPREAD; 27 | const SPREADWEIGHT = 0.6; // used for Spread mode 28 | 29 | const limits = ["1kyu", "2kyu", "3kyu", "4kyu", "5kyu", "6kyu", "7kyu", "8kyu"]; 30 | 31 | // https://docs.codewars.com/gamification/ranks 32 | // 3-8 dan added speculatively, based on trend 33 | const RANKTHRESHOLDS: { [rank: string]: number } = { 34 | "8kyu": 0, 35 | "7kyu": 20, 36 | "6kyu": 76, 37 | "5kyu": 229, 38 | "4kyu": 643, 39 | "3kyu": 1768, 40 | "2kyu": 4829, 41 | "1kyu": 13147, 42 | "1dan": 35759, 43 | "2dan": 97225, 44 | "3dan": 264305, 45 | "4dan": 718477, 46 | "5dan": 1953045, 47 | "6dan": 5308948, 48 | "7dan": 14431239, 49 | "8dan": 39228196, 50 | }; 51 | 52 | const RANKPOINTS: { [rank: string]: number } = { 53 | "8kyu": 2, 54 | "7kyu": 3, 55 | "6kyu": 8, 56 | "5kyu": 21, 57 | "4kyu": 55, 58 | "3kyu": 149, 59 | "2kyu": 404, 60 | "1kyu": 1097, 61 | }; 62 | 63 | // Format final output string 64 | function formatResult( 65 | user: string, 66 | ranks: [string, string][], 67 | target: string, 68 | mode: Mode, 69 | lang: Language | null 70 | ) { 71 | const maxLen = Math.max(...ranks.map((v) => String(v[0]).length)); 72 | const rankStr = 73 | (mode === EACH ? " " : " ") + 74 | ranks 75 | .map(([div, rank]) => (div == "0" ? "" : div.padEnd(maxLen + 1) + rank)) 76 | .filter((v) => v.length > 0) 77 | .join(mode === EACH ? "\nor " : "\nand "); 78 | return `${user} needs to complete: 79 | \`\`\` 80 | ${rankStr} 81 | \`\`\` 82 | to ${target}${lang ? ` in ${lang.name}` : ""}`; 83 | } 84 | 85 | async function getNextRank( 86 | score: number, 87 | user: string, 88 | targ: string | null, 89 | lang: string | null 90 | ): Promise<[string, number] | string> { 91 | // If target is a rank 92 | if (targ && /^[1-8](?:kyu|dan)$/.test(targ)) { 93 | const targScore = RANKTHRESHOLDS[targ]; 94 | if (targScore <= score) return "Target has already been reached"; 95 | return [`reach \`${targ}\``, targScore - score]; 96 | } 97 | 98 | // If target is a user 99 | if (targ) { 100 | const targScore = await getScore(targ, lang); 101 | if (targScore < score) return `${user} has already reached ${targ}'s rank`; 102 | return [`overtake ${targ}`, targScore - score + 1]; 103 | } 104 | 105 | // Otherwise take next rank above user's current 106 | let [nextRank, remaining] = Object.entries(RANKTHRESHOLDS) 107 | .map(([rank, points]): [string, number] => [rank, points - score]) 108 | .filter(([, v]) => v > 0) 109 | .reduce((a, c) => (a[1] < c[1] ? a : c)); 110 | nextRank = `reach \`${nextRank}\``; 111 | return [nextRank, remaining]; 112 | } 113 | 114 | // rankup 115 | export const data = async () => 116 | new SlashCommandBuilder() 117 | .setName("rankup") 118 | .setDescription("Find out how many Kata to complete to advance to the next rank, and more") 119 | .addStringOption((option) => 120 | option 121 | .setName("username") 122 | .setDescription("The Codewars username to query rankup statistics for") 123 | ) 124 | .addStringOption((option) => 125 | option.setName("target").setDescription("The target user or rank to reach") 126 | ) 127 | .addStringOption((option) => 128 | option 129 | .setName("language") 130 | .setDescription("The programming language to query rankup statistics for") 131 | .setAutocomplete(true) 132 | ) 133 | .addStringOption((option) => 134 | option 135 | .setName("mode") 136 | .setDescription("Choose your preferred method to reach your target") 137 | .addChoices(...modes.map((mode) => ({ name: mode.description, value: mode.name }))) 138 | ) 139 | .addStringOption((option) => 140 | option 141 | .setName("limit") 142 | .setDescription("Don't suggest Kata above this rank") 143 | .addChoices(...limits.map((limit) => ({ name: limit, value: limit }))) 144 | ) 145 | .addBooleanOption((option) => 146 | option.setName("ephemeral").setDescription("Don't show rank up statistics to others") 147 | ) 148 | .toJSON(); 149 | 150 | export const call = async (interaction: ChatInputCommandInteraction) => { 151 | const username = getUsername(interaction); 152 | const target = interaction.options.getString("target"); 153 | const language = await findLanguage(interaction.options.getString("language")); 154 | const mode = interaction.options.getString("mode") || DEFAULTMODE; 155 | const limit = interaction.options.getString("limit") || "1kyu"; 156 | const ephemeral = interaction.options.getBoolean("ephemeral") || false; 157 | checkBotPlayground(ephemeral, interaction); 158 | 159 | // Get user data 160 | let score: number = await getScore(username, language?.id ?? null); 161 | if (score == 0) { 162 | await interaction.reply({ 163 | content: `${username} has not started training ${language?.name ?? "Overall"}`, 164 | ephemeral, 165 | }); 166 | return; 167 | } 168 | 169 | const nextRankScore = await getNextRank(score, username, target, language?.id ?? null); 170 | if (typeof nextRankScore == "string") { 171 | await interaction.reply({ 172 | content: `${nextRankScore}${language ? ` in ${language.name}` : ""}`, 173 | ephemeral, 174 | }); 175 | return; 176 | } 177 | let [nextRank, remaining]: [string, number] = nextRankScore; 178 | 179 | // Get number of each kata required to reach the target 180 | const rankNums: [string, string][] = Object.entries(RANKPOINTS) 181 | .filter(([k]) => Number(k[0]) >= Number(String(limit)[0])) 182 | .sort((a, b) => b[1] - a[1]) 183 | .map(([rank, points]) => { 184 | if (points > remaining && rank != "8kyu") return ["0", ""]; 185 | let div: number; 186 | if (rank === "8kyu" || mode === EACH) { 187 | div = Math.ceil(remaining / points); 188 | } else { 189 | div = Math.floor(remaining / points); 190 | if (mode === SPREAD) div = Math.floor(div * SPREADWEIGHT); 191 | } 192 | if (mode !== EACH) remaining -= div * points; 193 | return [String(div), rank]; 194 | }); 195 | 196 | // Format results and send 197 | await interaction.reply({ 198 | content: formatResult(username, rankNums, nextRank, mode as Mode, language), 199 | ephemeral, 200 | }); 201 | }; 202 | --------------------------------------------------------------------------------