From 2151c5ed876872f7bfcb4bdeed675fbfcedaf21f Mon Sep 17 00:00:00 2001 From: Marcus Chan <76544623+mcpenguin@users.noreply.github.com> Date: Sun, 5 Mar 2023 11:53:14 -0500 Subject: [PATCH] 434 leetcode for codey (#455) * initial commit * added lc random command * intermediate * added tags and difficulty filter * fixed some stuff * lint * addressed comments * fixed lc id --- package.json | 2 + .../leetcode/leetcodeRandomCommandDetails.ts | 99 +++++++++++ .../leetcodeSpecificCommandDetails.ts | 46 +++++ src/commands/leetcode/leetcode.ts | 30 ++++ src/components/leetcode.ts | 160 ++++++++++++++++++ src/utils/markdown.ts | 6 + yarn.lock | 17 ++ 7 files changed, 360 insertions(+) create mode 100644 src/commandDetails/leetcode/leetcodeRandomCommandDetails.ts create mode 100644 src/commandDetails/leetcode/leetcodeSpecificCommandDetails.ts create mode 100644 src/commands/leetcode/leetcode.ts create mode 100644 src/components/leetcode.ts create mode 100644 src/utils/markdown.ts diff --git a/package.json b/package.json index a666179f..c473a8c5 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "sqlite": "^4.0.22", "sqlite3": "^5.0.2", "stable-marriage": "^1.0.2", + "turndown": "^7.1.1", "typescript": "^4.6.3", "winston": "^3.8.1" }, @@ -51,6 +52,7 @@ "@types/lodash": "^4.14.168", "@types/node": "^15.0.1", "@types/sqlite3": "^3.1.7", + "@types/turndown": "^5.0.1", "@typescript-eslint/eslint-plugin": "^4.22.0", "@typescript-eslint/parser": "^4.22.0", "eslint": "^7.25.0", diff --git a/src/commandDetails/leetcode/leetcodeRandomCommandDetails.ts b/src/commandDetails/leetcode/leetcodeRandomCommandDetails.ts new file mode 100644 index 00000000..c30fd8dc --- /dev/null +++ b/src/commandDetails/leetcode/leetcodeRandomCommandDetails.ts @@ -0,0 +1,99 @@ +import { container } from '@sapphire/framework'; +import { Message, TextBasedChannel } from 'discord.js'; +import { + CodeyCommandDetails, + CodeyCommandOptionType, + SapphireAfterReplyType, + SapphireMessageExecuteType, + SapphireMessageResponseWithMetadata, +} from '../../codeyCommand'; +import { CodeyUserError } from '../../codeyUserError'; +import { + getMessageForLeetcodeProblem, + getLeetcodeProblemDataFromId, + createInitialValuesForTags, + getListOfLeetcodeProblemIds, + LeetcodeDifficulty, + TOTAL_NUMBER_OF_PROBLEMS, +} from '../../components/leetcode'; +import { getRandomIntFrom1 } from '../../utils/num'; + +const leetcodeRandomExecuteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const difficulty = args['difficulty']; + const tag = args['tag']; + + return new SapphireMessageResponseWithMetadata('The problem will be loaded shortly...', { + difficulty, + tag, + messageFromUser, + }); +}; + +const leetcodeRandomAfterMessageReply: SapphireAfterReplyType = async ( + result, + _sentMessage, +): Promise => { + // The API might take more than 3 seconds to complete + // Which is more than the timeout for slash commands + // So we just send a separate message to the channel which the command was called from. + + const difficulty = result.metadata['difficulty']; + const tag = result.metadata['tag']; + const message = >result.metadata['messageFromUser']; + const channel = message.channel; + + let problemId; + if (typeof difficulty === 'undefined' && typeof tag === 'undefined') { + problemId = getRandomIntFrom1(TOTAL_NUMBER_OF_PROBLEMS); + } else { + const problemIds = await getListOfLeetcodeProblemIds(difficulty, tag); + const index = getRandomIntFrom1(problemIds.length) - 1; + if (problemIds.length === 0) { + throw new CodeyUserError(message, 'There are no problems with the specified filters.'); + } + problemId = problemIds[index]; + } + const problemData = await getLeetcodeProblemDataFromId(problemId); + const content = getMessageForLeetcodeProblem(problemData).slice(0, 2000); + + await channel?.send(content); + return; +}; + +export const leetcodeRandomCommandDetails: CodeyCommandDetails = { + name: 'random', + aliases: ['r'], + description: 'Get a random LeetCode problem.', + detailedDescription: `**Examples:** +\`${container.botPrefix}leetcode\`\n +\`${container.botPrefix}leetcode random\``, + + isCommandResponseEphemeral: false, + executeCommand: leetcodeRandomExecuteCommand, + afterMessageReply: leetcodeRandomAfterMessageReply, + options: [ + { + name: 'difficulty', + description: 'The difficulty of the problem.', + type: CodeyCommandOptionType.STRING, + required: false, + choices: [ + { name: 'Easy', value: 'easy' }, + { name: 'Medium', value: 'medium' }, + { name: 'Hard', value: 'hard' }, + ], + }, + { + name: 'tag', + description: 'The type of problem.', + type: CodeyCommandOptionType.STRING, + required: false, + choices: createInitialValuesForTags(), + }, + ], + subcommandDetails: {}, +}; diff --git a/src/commandDetails/leetcode/leetcodeSpecificCommandDetails.ts b/src/commandDetails/leetcode/leetcodeSpecificCommandDetails.ts new file mode 100644 index 00000000..de4038aa --- /dev/null +++ b/src/commandDetails/leetcode/leetcodeSpecificCommandDetails.ts @@ -0,0 +1,46 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + CodeyCommandOptionType, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; +import { CodeyUserError } from '../../codeyUserError'; +import { + getMessageForLeetcodeProblem, + getLeetcodeProblemDataFromId, +} from '../../components/leetcode'; + +const leetcodeSpecificExecuteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const problemId = args['problem-ID']; + if (!Number.isInteger(problemId)) { + throw new CodeyUserError(messageFromUser, 'Problem ID must be an integer.'); + } + const result = await getLeetcodeProblemDataFromId(problemId); + + return getMessageForLeetcodeProblem(result); +}; + +export const leetcodeSpecificCommandDetails: CodeyCommandDetails = { + name: 'specific', + aliases: ['spec', 's'], + description: 'Get a LeetCode problem with specified problem ID.', + detailedDescription: `**Examples:** +\`${container.botPrefix}leetcode specific 1\``, + + isCommandResponseEphemeral: false, + executeCommand: leetcodeSpecificExecuteCommand, + options: [ + { + name: 'problem-ID', + description: 'The problem ID.', + type: CodeyCommandOptionType.NUMBER, + required: true, + }, + ], + subcommandDetails: {}, +}; diff --git a/src/commands/leetcode/leetcode.ts b/src/commands/leetcode/leetcode.ts new file mode 100644 index 00000000..7ca28b9e --- /dev/null +++ b/src/commands/leetcode/leetcode.ts @@ -0,0 +1,30 @@ +import { Command } from '@sapphire/framework'; +import { CodeyCommand, CodeyCommandDetails } from '../../codeyCommand'; +import { leetcodeRandomCommandDetails } from '../../commandDetails/leetcode/leetcodeRandomCommandDetails'; +import { leetcodeSpecificCommandDetails } from '../../commandDetails/leetcode/leetcodeSpecificCommandDetails'; + +const leetcodeCommandDetails: CodeyCommandDetails = { + name: 'leetcode', + aliases: [], + description: 'Handle LeetCode functions.', + detailedDescription: ``, // leave blank for now + options: [], + subcommandDetails: { + random: leetcodeRandomCommandDetails, + specific: leetcodeSpecificCommandDetails, + }, + defaultSubcommandDetails: leetcodeRandomCommandDetails, +}; + +export class LeetcodeCommand extends CodeyCommand { + details = leetcodeCommandDetails; + + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + aliases: leetcodeCommandDetails.aliases, + description: leetcodeCommandDetails.description, + detailedDescription: leetcodeCommandDetails.detailedDescription, + }); + } +} diff --git a/src/components/leetcode.ts b/src/components/leetcode.ts new file mode 100644 index 00000000..57d43961 --- /dev/null +++ b/src/components/leetcode.ts @@ -0,0 +1,160 @@ +import axios from 'axios'; +import { APIApplicationCommandOptionChoice } from 'discord-api-types/v9'; +import { convertHtmlToMarkdown } from '../utils/markdown'; + +export const TOTAL_NUMBER_OF_PROBLEMS = 2577; + +const leetcodeIdUrl = 'https://lcid.cc/info'; +const leetcodeApiUrl = 'https://leetcode.com/graphql'; +const leetcodeUrl = 'https://leetcode.com/problems'; + +const LeetcodeTagsDict = { + array: 'Array', + string: 'String', + 'hash-table': 'Hash Table', + 'dynamic-programming': 'Dynamic Programming', + math: 'Math', + sorting: 'Sorting', + greedy: 'Greedy', + 'depth-first-search': 'Depth-First Search', + database: 'Database', + 'binary-search': 'Binary Search', + 'breadth-first-search': 'Breadth-First Search', + tree: 'Tree', + matrix: 'Matrix', + 'binary-tree': 'Binary Tree', + 'two-pointers': 'Two Pointers', + 'bit-manipulation': 'Bit Manipulation', + stack: 'Stack', + 'heap-priority-queue': 'Heap (Priority Queue)', + graph: 'Graph', + design: 'Design', + 'prefix-sum': 'Prefix Sum', + simulation: 'Simulation', + counting: 'Counting', + backtracking: 'Backtracking', + 'sliding-window': 'Sliding Window', +}; + +interface LeetcodeTopicTag { + name: string; +} + +export enum LeetcodeDifficulty { + 'Easy', + 'Medium', + 'Hard', +} + +interface LeetcodeIdProblemDataFromUrl { + difficulty: LeetcodeDifficulty; + likes: number; + dislikes: number; + categoryTitle: string; + frontendQuestionId: number; + paidOnly: boolean; + title: string; + titleSlug: string; + topicTags: LeetcodeTopicTag[]; + totalAcceptedRaw: number; + totalSubmissionRaw: number; +} + +interface LeetcodeProblemDataFromUrl { + data: { + question: { + content: string; + }; + }; +} + +interface LeetcodeProblemData { + difficulty: LeetcodeDifficulty; + likes: number; + dislikes: number; + categoryTitle: string; + paidOnly: boolean; + title: string; + titleSlug: string; + topicTags: LeetcodeTopicTag[]; + totalAcceptedRaw: number; + totalSubmissionRaw: number; + contentAsMarkdown: string; + problemId: number; +} + +export const createInitialValuesForTags = (): APIApplicationCommandOptionChoice[] => { + return [ + ...Object.entries(LeetcodeTagsDict).map((e) => { + return { + name: e[1], + value: e[0], + }; + }), + ]; +}; + +export const getLeetcodeProblemDataFromId = async ( + problemId: number, +): Promise => { + const resFromLeetcodeById: LeetcodeIdProblemDataFromUrl = ( + await axios.get(`${leetcodeIdUrl}/${problemId}`) + ).data; + const resFromLeetcode: LeetcodeProblemDataFromUrl = ( + await axios.get(leetcodeApiUrl, { + params: { + operationName: 'questionData', + variables: { + titleSlug: resFromLeetcodeById.titleSlug, + }, + query: + 'query questionData($titleSlug: String!) {\n question(titleSlug: $titleSlug) {\n questionId\n questionFrontendId\n boundTopicId\n title\n titleSlug\n content\n translatedTitle\n translatedContent\n isPaidOnly\n difficulty\n likes\n dislikes\n isLiked\n similarQuestions\n contributors {\n username\n profileUrl\n avatarUrl\n __typename\n }\n langToValidPlayground\n topicTags {\n name\n slug\n translatedName\n __typename\n }\n companyTagStats\n codeSnippets {\n lang\n langSlug\n code\n __typename\n }\n stats\n hints\n solution {\n id\n canSeeDetail\n __typename\n }\n status\n sampleTestCase\n metaData\n judgerAvailable\n judgeType\n mysqlSchemas\n enableRunCode\n enableTestMode\n envInfo\n libraryUrl\n __typename\n }\n}\n', + }, + }) + ).data; + + const result: LeetcodeProblemData = { + ...resFromLeetcodeById, + contentAsMarkdown: convertHtmlToMarkdown(resFromLeetcode.data.question.content), + problemId: problemId, + }; + return result; +}; + +export const getListOfLeetcodeProblemIds = async ( + difficulty?: LeetcodeDifficulty, + tag?: string, +): Promise => { + const filters = { + ...(typeof difficulty !== 'undefined' + ? { difficulty: difficulty.toString().toUpperCase() } + : {}), + ...(typeof tag !== 'undefined' ? { tags: [tag] } : {}), + }; + const resFromLeetcode = ( + await axios.get(leetcodeApiUrl, { + params: { + variables: { + categorySlug: '', + filters: filters, + limit: 2000, + skip: 0, + }, + query: + 'query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) {\n problemsetQuestionList: questionList(\n categorySlug: $categorySlug\n limit: $limit\n skip: $skip\n filters: $filters\n ) {\n total: totalNum\n questions: data {\n acRate\n difficulty\n freqBar\n frontendQuestionId: questionFrontendId\n isFavor\n paidOnly: isPaidOnly\n status\n title\n titleSlug\n topicTags {\n name\n id\n slug\n }\n hasSolution\n hasVideoSolution\n }\n }\n}\n ', + }, + }) + ).data; + const result = resFromLeetcode.data.problemsetQuestionList.questions.map( + (question: { frontendQuestionId: number }) => question.frontendQuestionId, + ); + return result; +}; + +export const getMessageForLeetcodeProblem = (leetcodeProblemData: LeetcodeProblemData): string => { + const title = `#${leetcodeProblemData.problemId}: ${leetcodeProblemData.title}`; + const content = leetcodeProblemData.contentAsMarkdown; + const url = `${leetcodeUrl}/${leetcodeProblemData.titleSlug}`; + const difficulty = leetcodeProblemData.difficulty; + return `**${title} - ${difficulty}**\n*Problem URL: ${url}*\n\n${content}`; +}; diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts new file mode 100644 index 00000000..292a2142 --- /dev/null +++ b/src/utils/markdown.ts @@ -0,0 +1,6 @@ +import TurndownService from 'turndown'; + +export const convertHtmlToMarkdown = (html: string): string => { + const turndownService = new TurndownService(); + return turndownService.turndown(html); +}; diff --git a/yarn.lock b/yarn.lock index 4a93ccf3..1149b641 100644 --- a/yarn.lock +++ b/yarn.lock @@ -372,6 +372,11 @@ dependencies: "@types/node" "*" +"@types/turndown@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.1.tgz#fcda7b02cda4c9d445be1440036df20f335b9387" + integrity sha512-N8Ad4e3oJxh9n9BiZx9cbe/0M3kqDpOTm2wzj13wdDUxDPjfjloWIJaquZzWE1cYTAHpjOH3rcTnXQdpEfS/SQ== + "@types/ws@^8.5.3": version "8.5.3" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" @@ -917,6 +922,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +domino@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/domino/-/domino-2.1.6.tgz#fe4ace4310526e5e7b9d12c7de01b7f485a57ffe" + integrity sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ== + dotenv@^8.2.0: version "8.6.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" @@ -2409,6 +2419,13 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" +turndown@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.1.1.tgz#96992f2d9b40a1a03d3ea61ad31b5a5c751ef77f" + integrity sha512-BEkXaWH7Wh7e9bd2QumhfAXk5g34+6QUmmWx+0q6ThaVOLuLUqsnkq35HQ5SBHSaxjSfSM7US5o4lhJNH7B9MA== + dependencies: + domino "^2.1.6" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"