Skip to content

Commit 2151c5e

Browse files
authored
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
1 parent 02199f5 commit 2151c5e

File tree

7 files changed

+360
-0
lines changed

7 files changed

+360
-0
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"sqlite": "^4.0.22",
4242
"sqlite3": "^5.0.2",
4343
"stable-marriage": "^1.0.2",
44+
"turndown": "^7.1.1",
4445
"typescript": "^4.6.3",
4546
"winston": "^3.8.1"
4647
},
@@ -51,6 +52,7 @@
5152
"@types/lodash": "^4.14.168",
5253
"@types/node": "^15.0.1",
5354
"@types/sqlite3": "^3.1.7",
55+
"@types/turndown": "^5.0.1",
5456
"@typescript-eslint/eslint-plugin": "^4.22.0",
5557
"@typescript-eslint/parser": "^4.22.0",
5658
"eslint": "^7.25.0",
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { container } from '@sapphire/framework';
2+
import { Message, TextBasedChannel } from 'discord.js';
3+
import {
4+
CodeyCommandDetails,
5+
CodeyCommandOptionType,
6+
SapphireAfterReplyType,
7+
SapphireMessageExecuteType,
8+
SapphireMessageResponseWithMetadata,
9+
} from '../../codeyCommand';
10+
import { CodeyUserError } from '../../codeyUserError';
11+
import {
12+
getMessageForLeetcodeProblem,
13+
getLeetcodeProblemDataFromId,
14+
createInitialValuesForTags,
15+
getListOfLeetcodeProblemIds,
16+
LeetcodeDifficulty,
17+
TOTAL_NUMBER_OF_PROBLEMS,
18+
} from '../../components/leetcode';
19+
import { getRandomIntFrom1 } from '../../utils/num';
20+
21+
const leetcodeRandomExecuteCommand: SapphireMessageExecuteType = async (
22+
_client,
23+
messageFromUser,
24+
args,
25+
): Promise<SapphireMessageResponseWithMetadata> => {
26+
const difficulty = <LeetcodeDifficulty | undefined>args['difficulty'];
27+
const tag = <string | undefined>args['tag'];
28+
29+
return new SapphireMessageResponseWithMetadata('The problem will be loaded shortly...', {
30+
difficulty,
31+
tag,
32+
messageFromUser,
33+
});
34+
};
35+
36+
const leetcodeRandomAfterMessageReply: SapphireAfterReplyType = async (
37+
result,
38+
_sentMessage,
39+
): Promise<unknown> => {
40+
// The API might take more than 3 seconds to complete
41+
// Which is more than the timeout for slash commands
42+
// So we just send a separate message to the channel which the command was called from.
43+
44+
const difficulty = <LeetcodeDifficulty | undefined>result.metadata['difficulty'];
45+
const tag = <string | undefined>result.metadata['tag'];
46+
const message = <Message<boolean>>result.metadata['messageFromUser'];
47+
const channel = <TextBasedChannel>message.channel;
48+
49+
let problemId;
50+
if (typeof difficulty === 'undefined' && typeof tag === 'undefined') {
51+
problemId = getRandomIntFrom1(TOTAL_NUMBER_OF_PROBLEMS);
52+
} else {
53+
const problemIds = await getListOfLeetcodeProblemIds(difficulty, tag);
54+
const index = getRandomIntFrom1(problemIds.length) - 1;
55+
if (problemIds.length === 0) {
56+
throw new CodeyUserError(message, 'There are no problems with the specified filters.');
57+
}
58+
problemId = problemIds[index];
59+
}
60+
const problemData = await getLeetcodeProblemDataFromId(problemId);
61+
const content = getMessageForLeetcodeProblem(problemData).slice(0, 2000);
62+
63+
await channel?.send(content);
64+
return;
65+
};
66+
67+
export const leetcodeRandomCommandDetails: CodeyCommandDetails = {
68+
name: 'random',
69+
aliases: ['r'],
70+
description: 'Get a random LeetCode problem.',
71+
detailedDescription: `**Examples:**
72+
\`${container.botPrefix}leetcode\`\n
73+
\`${container.botPrefix}leetcode random\``,
74+
75+
isCommandResponseEphemeral: false,
76+
executeCommand: leetcodeRandomExecuteCommand,
77+
afterMessageReply: leetcodeRandomAfterMessageReply,
78+
options: [
79+
{
80+
name: 'difficulty',
81+
description: 'The difficulty of the problem.',
82+
type: CodeyCommandOptionType.STRING,
83+
required: false,
84+
choices: [
85+
{ name: 'Easy', value: 'easy' },
86+
{ name: 'Medium', value: 'medium' },
87+
{ name: 'Hard', value: 'hard' },
88+
],
89+
},
90+
{
91+
name: 'tag',
92+
description: 'The type of problem.',
93+
type: CodeyCommandOptionType.STRING,
94+
required: false,
95+
choices: createInitialValuesForTags(),
96+
},
97+
],
98+
subcommandDetails: {},
99+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { container } from '@sapphire/framework';
2+
import {
3+
CodeyCommandDetails,
4+
CodeyCommandOptionType,
5+
SapphireMessageExecuteType,
6+
SapphireMessageResponse,
7+
} from '../../codeyCommand';
8+
import { CodeyUserError } from '../../codeyUserError';
9+
import {
10+
getMessageForLeetcodeProblem,
11+
getLeetcodeProblemDataFromId,
12+
} from '../../components/leetcode';
13+
14+
const leetcodeSpecificExecuteCommand: SapphireMessageExecuteType = async (
15+
_client,
16+
messageFromUser,
17+
args,
18+
): Promise<SapphireMessageResponse> => {
19+
const problemId = <number>args['problem-ID'];
20+
if (!Number.isInteger(problemId)) {
21+
throw new CodeyUserError(messageFromUser, 'Problem ID must be an integer.');
22+
}
23+
const result = await getLeetcodeProblemDataFromId(problemId);
24+
25+
return getMessageForLeetcodeProblem(result);
26+
};
27+
28+
export const leetcodeSpecificCommandDetails: CodeyCommandDetails = {
29+
name: 'specific',
30+
aliases: ['spec', 's'],
31+
description: 'Get a LeetCode problem with specified problem ID.',
32+
detailedDescription: `**Examples:**
33+
\`${container.botPrefix}leetcode specific 1\``,
34+
35+
isCommandResponseEphemeral: false,
36+
executeCommand: leetcodeSpecificExecuteCommand,
37+
options: [
38+
{
39+
name: 'problem-ID',
40+
description: 'The problem ID.',
41+
type: CodeyCommandOptionType.NUMBER,
42+
required: true,
43+
},
44+
],
45+
subcommandDetails: {},
46+
};

src/commands/leetcode/leetcode.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Command } from '@sapphire/framework';
2+
import { CodeyCommand, CodeyCommandDetails } from '../../codeyCommand';
3+
import { leetcodeRandomCommandDetails } from '../../commandDetails/leetcode/leetcodeRandomCommandDetails';
4+
import { leetcodeSpecificCommandDetails } from '../../commandDetails/leetcode/leetcodeSpecificCommandDetails';
5+
6+
const leetcodeCommandDetails: CodeyCommandDetails = {
7+
name: 'leetcode',
8+
aliases: [],
9+
description: 'Handle LeetCode functions.',
10+
detailedDescription: ``, // leave blank for now
11+
options: [],
12+
subcommandDetails: {
13+
random: leetcodeRandomCommandDetails,
14+
specific: leetcodeSpecificCommandDetails,
15+
},
16+
defaultSubcommandDetails: leetcodeRandomCommandDetails,
17+
};
18+
19+
export class LeetcodeCommand extends CodeyCommand {
20+
details = leetcodeCommandDetails;
21+
22+
public constructor(context: Command.Context, options: Command.Options) {
23+
super(context, {
24+
...options,
25+
aliases: leetcodeCommandDetails.aliases,
26+
description: leetcodeCommandDetails.description,
27+
detailedDescription: leetcodeCommandDetails.detailedDescription,
28+
});
29+
}
30+
}

src/components/leetcode.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import axios from 'axios';
2+
import { APIApplicationCommandOptionChoice } from 'discord-api-types/v9';
3+
import { convertHtmlToMarkdown } from '../utils/markdown';
4+
5+
export const TOTAL_NUMBER_OF_PROBLEMS = 2577;
6+
7+
const leetcodeIdUrl = 'https://lcid.cc/info';
8+
const leetcodeApiUrl = 'https://leetcode.com/graphql';
9+
const leetcodeUrl = 'https://leetcode.com/problems';
10+
11+
const LeetcodeTagsDict = {
12+
array: 'Array',
13+
string: 'String',
14+
'hash-table': 'Hash Table',
15+
'dynamic-programming': 'Dynamic Programming',
16+
math: 'Math',
17+
sorting: 'Sorting',
18+
greedy: 'Greedy',
19+
'depth-first-search': 'Depth-First Search',
20+
database: 'Database',
21+
'binary-search': 'Binary Search',
22+
'breadth-first-search': 'Breadth-First Search',
23+
tree: 'Tree',
24+
matrix: 'Matrix',
25+
'binary-tree': 'Binary Tree',
26+
'two-pointers': 'Two Pointers',
27+
'bit-manipulation': 'Bit Manipulation',
28+
stack: 'Stack',
29+
'heap-priority-queue': 'Heap (Priority Queue)',
30+
graph: 'Graph',
31+
design: 'Design',
32+
'prefix-sum': 'Prefix Sum',
33+
simulation: 'Simulation',
34+
counting: 'Counting',
35+
backtracking: 'Backtracking',
36+
'sliding-window': 'Sliding Window',
37+
};
38+
39+
interface LeetcodeTopicTag {
40+
name: string;
41+
}
42+
43+
export enum LeetcodeDifficulty {
44+
'Easy',
45+
'Medium',
46+
'Hard',
47+
}
48+
49+
interface LeetcodeIdProblemDataFromUrl {
50+
difficulty: LeetcodeDifficulty;
51+
likes: number;
52+
dislikes: number;
53+
categoryTitle: string;
54+
frontendQuestionId: number;
55+
paidOnly: boolean;
56+
title: string;
57+
titleSlug: string;
58+
topicTags: LeetcodeTopicTag[];
59+
totalAcceptedRaw: number;
60+
totalSubmissionRaw: number;
61+
}
62+
63+
interface LeetcodeProblemDataFromUrl {
64+
data: {
65+
question: {
66+
content: string;
67+
};
68+
};
69+
}
70+
71+
interface LeetcodeProblemData {
72+
difficulty: LeetcodeDifficulty;
73+
likes: number;
74+
dislikes: number;
75+
categoryTitle: string;
76+
paidOnly: boolean;
77+
title: string;
78+
titleSlug: string;
79+
topicTags: LeetcodeTopicTag[];
80+
totalAcceptedRaw: number;
81+
totalSubmissionRaw: number;
82+
contentAsMarkdown: string;
83+
problemId: number;
84+
}
85+
86+
export const createInitialValuesForTags = (): APIApplicationCommandOptionChoice[] => {
87+
return [
88+
...Object.entries(LeetcodeTagsDict).map((e) => {
89+
return {
90+
name: e[1],
91+
value: e[0],
92+
};
93+
}),
94+
];
95+
};
96+
97+
export const getLeetcodeProblemDataFromId = async (
98+
problemId: number,
99+
): Promise<LeetcodeProblemData> => {
100+
const resFromLeetcodeById: LeetcodeIdProblemDataFromUrl = (
101+
await axios.get(`${leetcodeIdUrl}/${problemId}`)
102+
).data;
103+
const resFromLeetcode: LeetcodeProblemDataFromUrl = (
104+
await axios.get(leetcodeApiUrl, {
105+
params: {
106+
operationName: 'questionData',
107+
variables: {
108+
titleSlug: resFromLeetcodeById.titleSlug,
109+
},
110+
query:
111+
'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',
112+
},
113+
})
114+
).data;
115+
116+
const result: LeetcodeProblemData = {
117+
...resFromLeetcodeById,
118+
contentAsMarkdown: convertHtmlToMarkdown(resFromLeetcode.data.question.content),
119+
problemId: problemId,
120+
};
121+
return result;
122+
};
123+
124+
export const getListOfLeetcodeProblemIds = async (
125+
difficulty?: LeetcodeDifficulty,
126+
tag?: string,
127+
): Promise<number[]> => {
128+
const filters = {
129+
...(typeof difficulty !== 'undefined'
130+
? { difficulty: difficulty.toString().toUpperCase() }
131+
: {}),
132+
...(typeof tag !== 'undefined' ? { tags: [tag] } : {}),
133+
};
134+
const resFromLeetcode = (
135+
await axios.get(leetcodeApiUrl, {
136+
params: {
137+
variables: {
138+
categorySlug: '',
139+
filters: filters,
140+
limit: 2000,
141+
skip: 0,
142+
},
143+
query:
144+
'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 ',
145+
},
146+
})
147+
).data;
148+
const result = resFromLeetcode.data.problemsetQuestionList.questions.map(
149+
(question: { frontendQuestionId: number }) => question.frontendQuestionId,
150+
);
151+
return result;
152+
};
153+
154+
export const getMessageForLeetcodeProblem = (leetcodeProblemData: LeetcodeProblemData): string => {
155+
const title = `#${leetcodeProblemData.problemId}: ${leetcodeProblemData.title}`;
156+
const content = leetcodeProblemData.contentAsMarkdown;
157+
const url = `${leetcodeUrl}/${leetcodeProblemData.titleSlug}`;
158+
const difficulty = leetcodeProblemData.difficulty;
159+
return `**${title} - ${difficulty}**\n*Problem URL: ${url}*\n\n${content}`;
160+
};

src/utils/markdown.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import TurndownService from 'turndown';
2+
3+
export const convertHtmlToMarkdown = (html: string): string => {
4+
const turndownService = new TurndownService();
5+
return turndownService.turndown(html);
6+
};

yarn.lock

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,11 @@
372372
dependencies:
373373
"@types/node" "*"
374374

375+
"@types/turndown@^5.0.1":
376+
version "5.0.1"
377+
resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.1.tgz#fcda7b02cda4c9d445be1440036df20f335b9387"
378+
integrity sha512-N8Ad4e3oJxh9n9BiZx9cbe/0M3kqDpOTm2wzj13wdDUxDPjfjloWIJaquZzWE1cYTAHpjOH3rcTnXQdpEfS/SQ==
379+
375380
"@types/ws@^8.5.3":
376381
version "8.5.3"
377382
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d"
@@ -917,6 +922,11 @@ doctrine@^3.0.0:
917922
dependencies:
918923
esutils "^2.0.2"
919924

925+
domino@^2.1.6:
926+
version "2.1.6"
927+
resolved "https://registry.yarnpkg.com/domino/-/domino-2.1.6.tgz#fe4ace4310526e5e7b9d12c7de01b7f485a57ffe"
928+
integrity sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==
929+
920930
dotenv@^8.2.0:
921931
version "8.6.0"
922932
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b"
@@ -2409,6 +2419,13 @@ tsutils@^3.21.0:
24092419
dependencies:
24102420
tslib "^1.8.1"
24112421

2422+
turndown@^7.1.1:
2423+
version "7.1.1"
2424+
resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.1.1.tgz#96992f2d9b40a1a03d3ea61ad31b5a5c751ef77f"
2425+
integrity sha512-BEkXaWH7Wh7e9bd2QumhfAXk5g34+6QUmmWx+0q6ThaVOLuLUqsnkq35HQ5SBHSaxjSfSM7US5o4lhJNH7B9MA==
2426+
dependencies:
2427+
domino "^2.1.6"
2428+
24122429
type-check@^0.4.0, type-check@~0.4.0:
24132430
version "0.4.0"
24142431
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"

0 commit comments

Comments
 (0)