Skip to content

Commit

Permalink
434 leetcode for codey (#455)
Browse files Browse the repository at this point in the history
* initial commit

* added lc random command

* intermediate

* added tags and difficulty filter

* fixed some stuff

* lint

* addressed comments

* fixed lc id
  • Loading branch information
mcpenguin authored Mar 5, 2023
1 parent 02199f5 commit 2151c5e
Show file tree
Hide file tree
Showing 7 changed files with 360 additions and 0 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
99 changes: 99 additions & 0 deletions src/commandDetails/leetcode/leetcodeRandomCommandDetails.ts
Original file line number Diff line number Diff line change
@@ -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<SapphireMessageResponseWithMetadata> => {
const difficulty = <LeetcodeDifficulty | undefined>args['difficulty'];
const tag = <string | undefined>args['tag'];

return new SapphireMessageResponseWithMetadata('The problem will be loaded shortly...', {
difficulty,
tag,
messageFromUser,
});
};

const leetcodeRandomAfterMessageReply: SapphireAfterReplyType = async (
result,
_sentMessage,
): Promise<unknown> => {
// 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 = <LeetcodeDifficulty | undefined>result.metadata['difficulty'];
const tag = <string | undefined>result.metadata['tag'];
const message = <Message<boolean>>result.metadata['messageFromUser'];
const channel = <TextBasedChannel>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: {},
};
46 changes: 46 additions & 0 deletions src/commandDetails/leetcode/leetcodeSpecificCommandDetails.ts
Original file line number Diff line number Diff line change
@@ -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<SapphireMessageResponse> => {
const problemId = <number>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: {},
};
30 changes: 30 additions & 0 deletions src/commands/leetcode/leetcode.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
160 changes: 160 additions & 0 deletions src/components/leetcode.ts
Original file line number Diff line number Diff line change
@@ -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<LeetcodeProblemData> => {
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<number[]> => {
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}`;
};
6 changes: 6 additions & 0 deletions src/utils/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import TurndownService from 'turndown';

export const convertHtmlToMarkdown = (html: string): string => {
const turndownService = new TurndownService();
return turndownService.turndown(html);
};
17 changes: 17 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 2151c5e

Please sign in to comment.