diff --git a/README.md b/README.md index d5c61359..03a71709 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ You can follow the instructions outlined [in this document](docs/SETUP.md). You can follow the steps [in this document](docs/CONTRIBUTING.md). +## Command Wiki + +Check out our [Command Wiki](docs/COMMAND-WIKI.md) for more details on CodeyBot commands! + ## License All rights reserved for images. diff --git a/docker-compose.yml b/docker-compose.yml index 1134b407..68fb4dd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,3 +10,4 @@ services: - ./src:/usr/app/src - ./logs:/usr/app/logs - ./db:/usr/app/db + - ./docs:/usr/app/docs diff --git a/docs/COMMAND-WIKI.md b/docs/COMMAND-WIKI.md new file mode 100644 index 00000000..787770ec --- /dev/null +++ b/docs/COMMAND-WIKI.md @@ -0,0 +1,327 @@ +# ADMIN +## ban +- **Aliases:** None +- **Description:** Ban a user. +- **Examples:**
`.ban @jeff spam` +- **Options:** + - ``user``: The user to ban. + - ``reason``: The reason why we are banning the user. +- **Subcommands:** None + +# COIN +## coin +- **Aliases:** None +- **Description:** Handle coin functions. +- **Examples:**
`.coin adjust @Codey 100`
`.coin adjust @Codey -100 Codey broke.`
`.coin`
`.coin check @Codey`
`.coin c @Codey`
`.coin info`
`.coin i`
`.coin update @Codey 100`
`.coin update @Codey 0 Reset Codey's balance.`
`.coin transfer @Codey 10`
`.coin transfer @Codey 15 Lost a bet to Codey ` +- **Options:** None +- **Subcommands:** `adjust`, `check`, `info`, `update`, `leaderboard`, `transfer` + +## coin adjust +- **Aliases:** `a` +- **Description:** Adjust the coin balance of a user. +- **Examples:**
`.coin adjust @Codey 100`
`.coin adjust @Codey -100 Codey broke.` +- **Options:** + - ``user``: The user to adjust the balance of. + - ``amount``: The amount to adjust the balance of the specified user by. + - ``reason``: The reason why we are adjusting the balance. +- **Subcommands:** None + +## coin check +- **Aliases:** `c`, `b`, `balance`, `bal` +- **Description:** The user to check the balance of. +- **Examples:**
`.coin check @Codey`
`.coin c @Codey` +- **Options:** + - ``user``: The user to check the balance of. +- **Subcommands:** None + +## coin info +- **Aliases:** `information`, `i` +- **Description:** Get info about Codey coin. +- **Examples:**
`.coin info`
`.coin information`
`.coin i` +- **Options:** None +- **Subcommands:** None + +## coin leaderboard +- **Aliases:** `lb` +- **Description:** Get the current coin leaderboard. +- **Examples:**
`.coin lb`
`.coin leaderboard` +- **Options:** None +- **Subcommands:** None + +## coin transfer +- **Aliases:** `t` +- **Description:** Transfer coins from your balance to another user. +- **Examples:**
`.coin transfer @Codey 10`
`.coin transfer @Codey 10 Lost a bet to @Codey` +- **Options:** + - ``user``: The user to transfer coins to. + - ``amount``: The amount to transfer to the specified user. + - ``reason``: The reason for transferring. +- **Subcommands:** None + +## coin update +- **Aliases:** `u` +- **Description:** Update the coin balance of a user. +- **Examples:**
`.coin update @Codey 100` +- **Options:** + - ``user``: The user to update the balance of. + - ``amount``: The amount to update the balance of the specified user to. + - ``reason``: The reason why we are updating the balance. +- **Subcommands:** None + +# COMPANY +## company +- **Aliases:** None +- **Description:** None +- **Examples:**
`.company add coinbase SRE`
`.company find coinbase`
+- **Options:** None +- **Subcommands:** `enroll`, `add`, `remove`, `find`, `profile` + +## company add +- **Aliases:** `a` +- **Description:** Add a company to your profile +- **Examples:**
`.company add https://www.crunchbase.com/organization/microsoft`
`.company a microsoft ` +- **Options:** +- **Subcommands:** None + +## company enroll +- **Aliases:** `e` +- **Description:** None +- **Examples:**
`.company enroll https://www.crunchbase.com/organization/microsoft`
`.company enroll microsoft` +- **Options:** +- **Subcommands:** None + +## company find +- **Aliases:** `f` +- **Description:** Find all individuals that work at the company. +- **Examples:**
`.company find https://www.crunchbase.com/organization/microsoft`
`.company f microsoft` +- **Options:** +- **Subcommands:** None + +## company profile +- **Aliases:** `p` +- **Description:** List all the companies you are associated with +- **Examples:**
`.company profile`
`.company p` +- **Options:** None +- **Subcommands:** None + +## company remove +- **Aliases:** `r` +- **Description:** Remove a company to your profile +- **Examples:**
`.company remove https://www.crunchbase.com/organization/microsoft`
`.company r microsoft ` +- **Options:** +- **Subcommands:** None + +# FUN +## flipcoin +- **Aliases:** `fc`, `flip`, `flip-coin`, `coin-flip`, `coinflip` +- **Description:** None +- **Examples:**
`.flip-coin`
`.fc`
`.flip`
`.coin-flip`
`.coinflip`
`.flipcoin` +- **Options:** None +- **Subcommands:** None + +## rolldice +- **Aliases:** `rd`, `roll`, `roll-dice`, `dice-roll`, `diceroll`, `dice` +- **Description:** Roll a dice! :game_die: +- **Examples:**
`.roll-dice 6`
`.dice-roll 30`
`.roll 100`
`.rd 4`
`.diceroll 2`
`.dice 1`
`.rolldice 10` +- **Options:** + - ``sides``: The number of sides on the die. +- **Subcommands:** None + +# GAMES +## bj +- **Aliases:** `blj`, `blackjack`, `21` +- **Description:** Play a Blackjack game to win some Codey coins! +- **Examples:**
`.bj 100`
`.blj 100` +- **Options:** + - ``bet``: A valid bet amount +- **Subcommands:** None + +## connect4 +- **Aliases:** None +- **Description:** Play Connect 4! +- **Examples:**
`.connect4`
`.connect 4 @user` +- **Options:** None +- **Subcommands:** None + +## rps +- **Aliases:** None +- **Description:** Play Rock, Paper, Scissors! +- **Examples:**
`.rps`
`.rps 10` +- **Options:** + - ``bet``: How much to bet - default is 10. +- **Subcommands:** None + +# INTERVIEWER +## interviewers +- **Aliases:** `int`, `interviewer` +- **Description:** Handle interviewer functions. +- **Examples:**
`.interviewer`
`.interviewer frontend` +- **Options:** None +- **Subcommands:** `clear`, `domain`, `pause`, `profile`, `resume`, `signup`, `list` + +## interviewer clear +- **Aliases:** `clr` +- **Description:** Clear all your interviewer data +- **Examples:**
`.interviewer clear` +- **Options:** None +- **Subcommands:** None + +## interviewer domain +- **Aliases:** `domain` +- **Description:** Add/remove a domain of your choice +- **Examples:**
`.interviewer domain frontend` +- **Options:** + - ``domain_name``: A valid domain name +- **Subcommands:** None + +## interviewer list +- **Aliases:** `ls` +- **Description:** List all interviewers or those under a specific domain +- **Examples:**
`.interviewer list`
`.interviewer list backend` +- **Options:** + - ``domain``: The domain to be examined +- **Subcommands:** None + +## interviewer pause +- **Aliases:** `ps` +- **Description:** Put your interviewer profile on pause +- **Examples:**
`.interviewer pause` +- **Options:** None +- **Subcommands:** None + +## interviewer profile +- **Aliases:** `pf` +- **Description:** Display your interviewer profile data +- **Examples:**
`.interviewer profile` +- **Options:** None +- **Subcommands:** None + +## interviewer resume +- **Aliases:** `resume` +- **Description:** Resume your interviewer profile +- **Examples:**
`.interviewer resume` +- **Options:** None +- **Subcommands:** None + +## interviewer signup +- **Aliases:** `signup` +- **Description:** Sign yourself up to be an interviewer! +- **Examples:**
`.interviewer signup www.calendly.com` +- **Options:** + - ``calendar_url``: A valid calendly.com or x.ai calendar link +- **Subcommands:** None + +# LEETCODE +## leetcode +- **Aliases:** None +- **Description:** Handle LeetCode functions. +- +- **Options:** None +- **Subcommands:** `random`, `specific` + +## leetcode random +- **Aliases:** `r` +- **Description:** Get a random LeetCode problem. +- **Examples:**
`.leetcode`n
`.leetcode random` +- **Options:** + - ``difficulty``: The difficulty of the problem. +- **Subcommands:** None + +## leetcode specific +- **Aliases:** `spec`, `s` +- **Description:** Get a LeetCode problem with specified problem ID. +- **Examples:**
`.leetcode specific 1` +- **Options:** + - ``problem-id``: The problem ID. +- **Subcommands:** None + +# MISCELLANEOUS +## help +- **Aliases:** `wiki` +- **Description:** Get the URL to the wiki page. +- **Examples:**
`.help`
`.wiki` +- **Options:** None +- **Subcommands:** None + +## info +- **Aliases:** None +- **Description:** Get Codey information - app version, repository link and issue templates. +- **Examples:**
`.info` +- **Options:** None +- **Subcommands:** None + +## member +- **Aliases:** None +- **Description:** Get CSC membership information of a user. +- **Examples:**
`.member [id]` +- **Options:** + - ``uwid``: The Quest ID of the user. +- **Subcommands:** None + +## ping +- **Aliases:** `pong` +- **Description:** Ping the bot to see if it is alive. :ping_pong: +- **Examples:**
`.ping`
`.pong` +- **Options:** None +- **Subcommands:** None + +## uptime +- **Aliases:** `up`, `timeup` +- **Description:** None +- **Examples:**
`.uptime`
`.up`
`.timeup` +- **Options:** None +- **Subcommands:** None + +# PROFILE +## profile +- **Aliases:** `userprofile`, `aboutme` +- **Description:** Handle user profile functions. +- **Examples:**
`.profile @Codey` +- **Options:** None +- **Subcommands:** `about`, `grad`, `set` + +## profile about +- **Aliases:** `a` +- **Description:** Display user profile. +- **Examples:**
`.profile about @Codey`
`.profile a @Codey` +- **Options:** + - ``user``: The user to give profile of. +- **Subcommands:** None + +## profile grad +- **Aliases:** `g` +- **Description:** Update Grad Roles. +- **Examples:**
`.profile grad`
`.profile g` +- **Options:** None +- **Subcommands:** None + +## profile set +- **Aliases:** `s` +- **Description:** Set parameters of user profile. +- **Examples:**
`.profile set @Codey`
`.profile a @Codey` +- **Options:** + - ``customization``: The customization to be set for the user. + - ``description``: The description of the customization to be set for the user. +- **Subcommands:** None + +# SUGGESTION +## suggestion +- **Aliases:** ``suggest`` +- **Description:** Handle suggestion functions. +- This command will forward a suggestion to the CSC Discord Mods. Please note that your suggestion is not anonymous, your Discord username and ID will be recorded. If you don't want to make a suggestion in public, you could use this command via a DM to Codey instead. + **Examples:** + ``.suggestion I want a new Discord channel named #hobbies.`` +- **Options:** + - ``details``: Details of your suggestion +- **Subcommands:** ``list``, ``update``, ``create`` + +# COFFEE CHAT +## coffee +- **Aliases:** None +- **Description:** Handle coffee chat functions. +- **Examples:** + ``.coffee match`` + ``.coffee test 10`` +- **Options:** None +- **Subcommands:** ``match``, ``test`` + diff --git a/src/commandDetails/miscellaneous/help.ts b/src/commandDetails/miscellaneous/help.ts index aa9a90d1..a0e6effd 100644 --- a/src/commandDetails/miscellaneous/help.ts +++ b/src/commandDetails/miscellaneous/help.ts @@ -5,7 +5,7 @@ import { SapphireMessageResponse, } from '../../codeyCommand'; -const wikiLink = 'https://github.com/uwcsc/codeybot/wiki/Command-Help'; +const wikiLink = 'https://github.com/uwcsc/codeybot/blob/main/docs/COMMAND-WIKI.md'; const helpExecuteCommand: SapphireMessageExecuteType = ( _client, diff --git a/src/events/ready.ts b/src/events/ready.ts index 105277f7..2b7aba1c 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -5,6 +5,7 @@ import { initEmojis } from '../components/emojis'; import { vars } from '../config'; import { logger } from '../logger/default'; import { getRepositoryReleases } from '../utils/github'; +import { updateWiki } from '../utils/updateWiki'; const dev = process.env.NODE_ENV !== 'production'; @@ -45,4 +46,5 @@ export const initReady = (client: Client): void => { sendReady(client); initCrons(client); initEmojis(client); + updateWiki(); }; diff --git a/src/utils/updateWiki.ts b/src/utils/updateWiki.ts new file mode 100644 index 00000000..8ced3243 --- /dev/null +++ b/src/utils/updateWiki.ts @@ -0,0 +1,258 @@ +import { statSync, readdirSync } from 'fs'; +import { appendFile, writeFile, readdir, readFile } from 'fs/promises'; +import { logger } from '../logger/default'; + +// File Paths +const wikiPath = 'docs/COMMAND-WIKI.md'; +const commandsDir = 'src/commands'; +const commandDetailsDir = 'src/commandDetails'; + +// Commands with subcommands, to be handled separately +const retrieveCompoundCommands = (): string[] => { + const compoundCommands: string[] = []; + + const filesAndDirs = readdirSync(commandsDir); + const directories = filesAndDirs.filter((file) => + statSync(`${commandsDir}/${file}`).isDirectory(), + ); + + for (const dir of directories) { + const subDir = `${commandsDir}/${dir}`; + const files = readdirSync(subDir); + if (files.length === 1 && files[0] === `${dir}.ts`) { + compoundCommands.push(dir); + } + } + + return compoundCommands; +}; + +// As of Feb 8 2024, this should be ['coin', 'company', 'interviewer', 'leetcode', 'profile']; +const compoundCommands: string[] = retrieveCompoundCommands(); + +// RegEx patterns to extract info +const commandDetailsPattern = /.*const .+CommandDetails: CodeyCommandDetails = {([\s\S]*?)\n}/; +const namePattern = /name: '(.*?)'/; +const aliasesPattern = /aliases: \[([\s\S]*?)\]/; +const descriptionPattern = /description: '(.*?)'/; +const detailedDescriptionPattern = /detailedDescription: `([\s\S]*?)`,/; +const optionsPattern = /options: \[([\s\S]*?)\]/; +const subcommandDetailsPattern = /subcommandDetails: {([\s\S]*?)},/; + +// ----------------------------------- START OF UTILITY FUNCTIONS ---------------------------- // + +const formatCommandName = async ( + name: string | undefined, + baseCmd: string | undefined = undefined, +) => { + let formattedName = ''; + if (name === undefined) { + formattedName = 'None'; + } else { + formattedName = name; + } + + if (baseCmd === undefined) { + await appendFile(wikiPath, `## ${formattedName}\n`); + } else { + await appendFile(wikiPath, `## ${baseCmd} ${formattedName}\n`); + } +}; + +const formatAliases = async (aliases: string | undefined) => { + let formattedAliases = ''; + if (aliases === undefined || aliases === '') { + formattedAliases = 'None'; + } else { + formattedAliases = aliases + .replace(/'/g, '') + .split(', ') + .map((alias) => `\`${alias}\``) + .join(', '); + } + await appendFile(wikiPath, `- **Aliases:** ${formattedAliases}\n`); +}; + +const formatDescription = async (descr: string | undefined) => { + let formattedDescription = ''; + if (descr === undefined) { + formattedDescription = 'None'; + } else { + formattedDescription = descr; + } + await appendFile(wikiPath, `- **Description:** ${formattedDescription}\n`); +}; + +const formatDetailedDescription = async (listStr: string | undefined) => { + let formattedDetailedDescription = ''; + if (listStr === undefined) { + formattedDetailedDescription = 'None'; + } else { + const newlineList = listStr.replace(/\n/g, '
'); + const botPrefixList = newlineList.replace(new RegExp('\\${container.botPrefix}', 'g'), '.'); + const bulletList = botPrefixList.replace(/\\/g, ''); + formattedDetailedDescription = bulletList; + } + await appendFile(wikiPath, `- ${formattedDetailedDescription}\n`); +}; + +const formatOptions = async (options: string[] | undefined) => { + if (options === undefined || options[0] === '') { + await appendFile(wikiPath, `- **Options:** None\n`); + } else { + let fieldMatch; + const extractedOptions = []; + const optionFieldsPattern = /\{\s*name: '(.*?)',\s*description: '(.*?)'/g; + while ((fieldMatch = optionFieldsPattern.exec(options![0])) !== null) { + const name = fieldMatch[1]; + const description = fieldMatch[2]; + extractedOptions.push({ name, description }); + } + await appendFile(wikiPath, `- **Options:** \n`); + for (const desc of extractedOptions) { + await appendFile(wikiPath, ` - \`\`${desc.name}\`\`: ${desc.description}\n`); + } + } +}; + +const formatSubcommandDetails = async (subcommandDetails: string | undefined) => { + let formattedSubcommandDetails = ''; + if (subcommandDetails === undefined || subcommandDetails === '') { + formattedSubcommandDetails = 'None'; + } else { + const regex = /\b(\w+)\s*:/g; + const matches = subcommandDetails.match(regex); + if (matches) { + const subcommandList = matches.map((match) => match.trim().replace(/:$/, '')).join(', '); + formattedSubcommandDetails = subcommandList + .replace(/'/g, '') + .split(', ') + .map((subcmd) => `\`${subcmd}\``) + .join(', '); + } + } + await appendFile(wikiPath, `- **Subcommands:** ${formattedSubcommandDetails}\n\n`); +}; + +const extractAndFormat = async ( + codeSection: string, + baseCmd: string | undefined = undefined, +): Promise => { + // Extract info pieces + const name = codeSection.match(namePattern)?.[1]; + const aliases = codeSection.match(aliasesPattern)?.[1]; + const description = codeSection.match(descriptionPattern)?.[1]; + const detailedDescription = codeSection.match(detailedDescriptionPattern)?.[1]; + const options = codeSection.match(optionsPattern)?.[1].split(', '); + const subcommandDetails = codeSection.match(subcommandDetailsPattern)?.[1]; + + // Just in case command name cannot be extracted, which is not to be expected + if (name === undefined) { + logger.error({ + message: `Could not find command name from ${codeSection}`, + }); + return; + } + + // Format the info + if (baseCmd === undefined) { + await formatCommandName(name); + } else { + await formatCommandName(name, baseCmd); + } + await formatAliases(aliases); + await formatDescription(description); + await formatDetailedDescription(detailedDescription); + await formatOptions(options); + await formatSubcommandDetails(subcommandDetails); +}; + +// ----------------------------------- END OF UTILITY FUNCTIONS ---------------------------- // + +export const updateWiki = async (): Promise => { + logger.info({ + message: 'Updating wiki...', + }); + + // Refresh COMMAND-WIKI.md + await writeFile(wikiPath, ''); + + const filesAndDirs = await readdir(commandDetailsDir); + const directories = filesAndDirs.filter((file) => + statSync(`${commandDetailsDir}/${file}`).isDirectory(), + ); + + for (const dir of directories) { + if (!compoundCommands.includes(dir)) { + await appendFile(wikiPath, `# ${dir.toUpperCase()}\n`); + const subDir = `${commandDetailsDir}/${dir}`; + const files = await readdir(subDir); + for (const file of files) { + const filePath = `${subDir}/${file}`; + const content = await readFile(filePath, 'utf-8'); + const match = content.match(commandDetailsPattern); + if (match) { + const codeSection = match[1]; + await extractAndFormat(codeSection); + } + } + } else { + await appendFile(wikiPath, `# ${dir.toUpperCase()}\n`); + const mainCommandDir = `${commandsDir}/${dir}`; + const subCommandsdir = `${commandDetailsDir}/${dir}`; + + // Retrieve overview info + const mainCommandFiles = await readdir(mainCommandDir); + for (const file of mainCommandFiles) { + const filePath = `${mainCommandDir}/${file}`; + const content = await readFile(filePath, 'utf-8'); + const match = content.match(commandDetailsPattern); + if (match) { + const codeSection = match[1]; + await extractAndFormat(codeSection); + } + } + + // Retrieve subcommand infos + const subCommandFiles = await readdir(subCommandsdir); + for (const file of subCommandFiles) { + const filePath = `${subCommandsdir}/${file}`; + const content = await readFile(filePath, 'utf-8'); + const match = content.match(commandDetailsPattern); + if (match) { + const codeSection = match[1]; + await extractAndFormat(codeSection, dir); + } + } + } + } + + // Harcoding info for suggestion until it can be migrated to CodeyCommand framework + const suggestionContents = `# SUGGESTION +## suggestion +- **Aliases:** \`\`suggest\`\` +- **Description:** Handle suggestion functions. +- This command will forward a suggestion to the CSC Discord Mods. Please note that your suggestion is not anonymous, your Discord username and ID will be recorded. If you don't want to make a suggestion in public, you could use this command via a DM to Codey instead. + **Examples:** + \`\`.suggestion I want a new Discord channel named #hobbies.\`\` +- **Options:** + - \`\`details\`\`: Details of your suggestion +- **Subcommands:** \`\`list\`\`, \`\`update\`\`, \`\`create\`\``; + await appendFile(wikiPath, `${suggestionContents}\n\n`); + + // Harcoding info for coffechat until it can be migrated to CodeyCommand framework + const coffeeContents = `# COFFEE CHAT +## coffee +- **Aliases:** None +- **Description:** Handle coffee chat functions. +- **Examples:** + \`\`.coffee match\`\` + \`\`.coffee test 10\`\` +- **Options:** None +- **Subcommands:** \`\`match\`\`, \`\`test\`\``; + await appendFile(wikiPath, `${coffeeContents}\n\n`); + + logger.info({ + message: 'Wiki successfully updated.', + }); +};