diff --git a/docs/COMMAND-WIKI.md b/docs/COMMAND-WIKI.md index c2cc7eca..67d5943f 100644 --- a/docs/COMMAND-WIKI.md +++ b/docs/COMMAND-WIKI.md @@ -304,6 +304,35 @@ - ``description``: The description of the customization to be set for the user. - **Subcommands:** None +# UWFLOW +## uwflow +- **Aliases:** None +- **Description:** Handle UWFlow commands. +- **Examples:** +- **Options:** None +- **Subcommands:** `info`, `req`, `search` + +## uwflow info +- **Aliases:** `information`, `i` +- **Description:** Get course information +- **Examples:**
`.uwflow info cs135`
`.uwflow information cs246`
`.uwflow i cs240` +- **Options:** +- **Subcommands:** None + +## uwflow req +- **Aliases:** `requisite` +- **Description:** Get course requisites +- **Examples:**
`.uwflow req cs135`
`.uwflow requisite cs246` +- **Options:** +- **Subcommands:** None + +## uwflow search +- **Aliases:** None +- **Description:** Search for courses in specified range +- **Examples:**
`.uwflow search CS 100 200`
`.uwflow search cs 100 200` +- **Options:** +- **Subcommands:** None + # SUGGESTION ## suggestion - **Aliases:** ``suggest`` diff --git a/src/commandDetails/uwflow/info.ts b/src/commandDetails/uwflow/info.ts new file mode 100644 index 00000000..7b4d9fda --- /dev/null +++ b/src/commandDetails/uwflow/info.ts @@ -0,0 +1,87 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + CodeyCommandOptionType, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; +import { courseInfo, getCourseInfo } from '../../components/uwflow'; +import { EmbedBuilder } from 'discord.js'; + +const uwflowInfoExecuteCommand: SapphireMessageExecuteType = async ( + _client, + _messageFromUser, + args, +): Promise => { + const courseCodeArg = args['course_code']; + + // If no argument is passed, return default information + if (courseCodeArg === undefined) { + const defaultEmbed = new EmbedBuilder() + .setColor('Blue') + .setTitle('General Information') + .setDescription('UWFlow is a website where students can view course reviews and ratings.'); + return { embeds: [defaultEmbed] }; + } + + // Standardize the course code (i.e. cs 135, CS135, CS 135 becomes cs135 for the GraphQL query) + const courseCode = courseCodeArg.split(' ').join('').toLowerCase(); + + const result: courseInfo | number = await getCourseInfo(courseCode); + + // If mistyped course code or course doesn't exist + if (result === -1) { + const errorDesc = 'Oops, that course does not exist!'; + const courseEmbed = new EmbedBuilder() + .setColor('Red') + .setTitle(`Information for ${courseCode.toUpperCase()}`) + .setDescription(errorDesc); + return { embeds: [courseEmbed] }; + } + + const info = result; + + const code = info.code.toUpperCase(); + const name = info.name; + const description = info.description; + const liked = (info.liked * 100).toFixed(2); + const easy = (info.easy * 100).toFixed(2); + const useful = (info.useful * 100).toFixed(2); + + const courseEmbed = new EmbedBuilder() + .setColor('Green') + .setTitle(`Information for ${code}`) + .addFields( + { name: 'Course code', value: code, inline: false }, + { name: 'Course name', value: name, inline: false }, + { name: 'Course description', value: description, inline: false }, + { name: 'Like ratings', value: `${liked}%`, inline: true }, + { name: 'Easy ratings', value: `${easy}%`, inline: true }, + { name: 'Useful ratings', value: `${useful}%`, inline: true }, + ); + + return { embeds: [courseEmbed] }; +}; + +export const uwflowInfoCommandDetails: CodeyCommandDetails = { + name: 'info', + aliases: ['information', 'i'], + description: 'Get course information', + detailedDescription: `**Examples:** +\`${container.botPrefix}uwflow info cs135\` +\`${container.botPrefix}uwflow information cs246\` +\`${container.botPrefix}uwflow i cs240\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Getting information about UWFlow:', + executeCommand: uwflowInfoExecuteCommand, + options: [ + { + name: 'course_code', + description: 'The course code. Examples: cs135, cs 135, CS135, CS 135', + type: CodeyCommandOptionType.STRING, + required: false, + }, + ], + subcommandDetails: {}, +}; diff --git a/src/commandDetails/uwflow/req.ts b/src/commandDetails/uwflow/req.ts new file mode 100644 index 00000000..87fc3fa2 --- /dev/null +++ b/src/commandDetails/uwflow/req.ts @@ -0,0 +1,73 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + CodeyCommandOptionType, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; +import { courseReqs, getCourseReqs } from '../../components/uwflow'; +import { EmbedBuilder } from 'discord.js'; + +const uwflowReqExecuteCommand: SapphireMessageExecuteType = async ( + _client, + _messageFromUser, + args, +): Promise => { + const courseCodeArg = args['course_code']; + + // Standardize the course code (i.e. cs 135, CS135, CS 135 becomes cs135 for the GraphQL query) + const courseCode = courseCodeArg.split(' ').join('').toLowerCase(); + + const result: courseReqs | number = await getCourseReqs(courseCode); + + // If mistyped course code or course doesn't exist + if (result === -1) { + const errorDesc = 'Oops, that course does not exist!'; + const courseEmbed = new EmbedBuilder() + .setColor('Red') + .setTitle(`Information for ${courseCode.toUpperCase()}`) + .setDescription(errorDesc); + return { embeds: [courseEmbed] }; + } + + const requisites = result; + + const code = requisites.code.toUpperCase(); + const antireqs = requisites.antireqs; + const prereqs = requisites.prereqs; + const coreqs = requisites.coreqs; + + const courseEmbed = new EmbedBuilder() + .setColor('Green') + .setTitle(`Requisites for ${code}`) + .addFields( + { name: 'Course code', value: code, inline: false }, + { name: 'Antirequisites', value: antireqs, inline: false }, + { name: 'Prerequisites', value: prereqs, inline: false }, + { name: 'Corequisites', value: coreqs, inline: false }, + ); + + return { embeds: [courseEmbed] }; +}; + +export const uwflowReqCommandDetails: CodeyCommandDetails = { + name: 'req', + aliases: ['requisite'], + description: 'Get course requisites', + detailedDescription: `**Examples:** +\`${container.botPrefix}uwflow req cs135\` +\`${container.botPrefix}uwflow requisite cs246\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Getting information from UWFlow:', + executeCommand: uwflowReqExecuteCommand, + options: [ + { + name: 'course_code', + description: 'The course code. Examples: cs135, cs 135, CS135, CS 135', + type: CodeyCommandOptionType.STRING, + required: true, + }, + ], + subcommandDetails: {}, +}; diff --git a/src/commandDetails/uwflow/search.ts b/src/commandDetails/uwflow/search.ts new file mode 100644 index 00000000..81700f2c --- /dev/null +++ b/src/commandDetails/uwflow/search.ts @@ -0,0 +1,93 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + CodeyCommandOptionType, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; +import { searchResults, getSearchResults } from '../../components/uwflow'; +import { EmbedBuilder } from 'discord.js'; + +const uwflowSearchExecuteCommand: SapphireMessageExecuteType = async ( + _client, + _messageFromUser, + args, +): Promise => { + const courseArg = args['course']; + const min = args['min']; + const max = args['max']; + + // Standardize the course initials (i.e. CS becomes cs for the GraphQL query) + const course = courseArg.toLowerCase(); + + const results: searchResults[] | number = await getSearchResults(course, min, max); + + // If mistyped course initials or course doesn't exist + if (results === -1) { + const errorDesc = 'UWFlow returned no data'; + const courseEmbed = new EmbedBuilder() + .setColor('Red') + .setTitle(`Information for query of ${course.toUpperCase()} courses in range ${min} - ${max}`) + .setDescription(errorDesc); + return { embeds: [courseEmbed] }; + } + + const resultArray = results; + // If no courses fit the range + if (resultArray.length < 1) { + const desc = 'No courses suit the query'; + const embed = new EmbedBuilder() + .setColor('Orange') + .setTitle(`Information for query of ${course} courses in range ${min} - ${max}`) + .setDescription(desc); + return { embeds: [embed] }; + } + + const courseArray: string[] = []; + for (const result of resultArray) { + courseArray.push(result.code); + } + + const desc = courseArray.join(', '); + + const resultEmbed = new EmbedBuilder() + .setColor('Green') + .setTitle(`Information for query of ${course} courses in range ${min} - ${max}`) + .setDescription(desc); + + return { embeds: [resultEmbed] }; +}; + +export const uwflowSearchCommandDetails: CodeyCommandDetails = { + name: 'search', + aliases: [], + description: 'Search for courses in specified range', + detailedDescription: `**Examples:** +\`${container.botPrefix}uwflow search CS 100 200\` +\`${container.botPrefix}uwflow search cs 100 200\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Getting information from UWFlow:', + executeCommand: uwflowSearchExecuteCommand, + options: [ + { + name: 'course', + description: 'The initials of the course. Examples: CS, cs, MATH, math', + type: CodeyCommandOptionType.STRING, + required: true, + }, + { + name: 'min', + description: 'The minimum code of the course', + type: CodeyCommandOptionType.INTEGER, + required: true, + }, + { + name: 'max', + description: 'The maximum code of the course', + type: CodeyCommandOptionType.INTEGER, + required: true, + }, + ], + subcommandDetails: {}, +}; diff --git a/src/commands/uwflow/uwflow.ts b/src/commands/uwflow/uwflow.ts new file mode 100644 index 00000000..c4c7e142 --- /dev/null +++ b/src/commands/uwflow/uwflow.ts @@ -0,0 +1,32 @@ +import { Command } from '@sapphire/framework'; +import { uwflowInfoCommandDetails } from '../../commandDetails/uwflow/info'; +import { uwflowReqCommandDetails } from '../../commandDetails/uwflow/req'; +import { uwflowSearchCommandDetails } from '../../commandDetails/uwflow/search'; +import { CodeyCommand, CodeyCommandDetails } from '../../codeyCommand'; + +const uwflowCommandDetails: CodeyCommandDetails = { + name: 'uwflow', + aliases: [], + description: 'Handle UWFlow commands.', + detailedDescription: `**Examples:**`, + options: [], + subcommandDetails: { + info: uwflowInfoCommandDetails, + req: uwflowReqCommandDetails, + search: uwflowSearchCommandDetails, + }, + defaultSubcommandDetails: uwflowInfoCommandDetails, +}; + +export class UWFlowCommand extends CodeyCommand { + details = uwflowCommandDetails; + + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + aliases: uwflowCommandDetails.aliases, + description: uwflowCommandDetails.description, + detailedDescription: uwflowCommandDetails.detailedDescription, + }); + } +} diff --git a/src/components/uwflow.ts b/src/components/uwflow.ts new file mode 100644 index 00000000..9e871cb4 --- /dev/null +++ b/src/components/uwflow.ts @@ -0,0 +1,214 @@ +import axios from 'axios'; + +// UWFlow API URL +const uwflowApiUrl = 'https://uwflow.com/graphql'; + +// Course info interface for UWFlow +interface courseInfoFromUrl { + data: { + course: [ + { + code: string; + name: string; + description: string; + rating: { + liked: number; + easy: number; + useful: number; + filled_count: number; + comment_count: number; + }; + }, + ]; + }; +} + +// Course info interface for CodeyBot +export interface courseInfo { + code: string; + name: string; + description: string; + liked: number; + easy: number; + useful: number; + filled_count: number; + comment_count: number; +} + +// Course requisites interface for UWFlow +interface courseReqsFromUrl { + data: { + course: [ + { + code: string; + id: number; + antireqs: string | null; + prereqs: string | null; + coreqs: string | null; + }, + ]; + }; +} + +// Course requisites interface for CodeyBot +export interface courseReqs { + code: string; + antireqs: string; + prereqs: string; + coreqs: string; +} + +// Search results interface for UWflow +interface searchResultsFromUrl { + data: { + search_courses: [ + { + name: string; + code: string; + has_prereqs: boolean; + }, + ]; + }; +} + +// Search results interface for CodeyBot +export interface searchResults { + name: string; + code: string; + has_prereqs: boolean; +} + +// Format string +const formatInput = (input: string | null): string => { + if (input === null) { + return 'None'; + } + + return input; +}; + +// Retrieve course info +export const getCourseInfo = async (courseCode: string): Promise => { + const resultFromUWFLow: courseInfoFromUrl = ( + await axios.post(uwflowApiUrl, { + operationName: 'getCourse', + variables: { + code: courseCode, + }, + query: `query getCourse($code: String) { + course(where: { code: { _eq: $code } }) { + code + name + description + rating { + liked + easy + useful + filled_count + comment_count + } + } + }`, + }) + ).data; + + // If no data is found, return -1 to signal error + if (resultFromUWFLow.data.course.length < 1) { + return -1; + } + + const result: courseInfo = { + code: resultFromUWFLow.data.course[0].code, + name: resultFromUWFLow.data.course[0].name, + description: resultFromUWFLow.data.course[0].description, + liked: resultFromUWFLow.data.course[0].rating.liked, + easy: resultFromUWFLow.data.course[0].rating.easy, + useful: resultFromUWFLow.data.course[0].rating.useful, + filled_count: resultFromUWFLow.data.course[0].rating.filled_count, + comment_count: resultFromUWFLow.data.course[0].rating.comment_count, + }; + + return result; +}; + +// Retrieve course requisites +export const getCourseReqs = async (courseCode: string): Promise => { + const resultFromUWFLow: courseReqsFromUrl = ( + await axios.post(uwflowApiUrl, { + operationName: 'getCourse', + variables: { + code: courseCode, + }, + query: `query getCourse($code: String) { + course(where: { code: { _eq: $code }}) { + code + id + antireqs + prereqs + coreqs + } + }`, + }) + ).data; + + // If no data is found, return -1 to signal error + if (resultFromUWFLow.data.course.length < 1) { + return -1; + } + + const result: courseReqs = { + code: resultFromUWFLow.data.course[0].code, + antireqs: formatInput(resultFromUWFLow.data.course[0].antireqs), + prereqs: formatInput(resultFromUWFLow.data.course[0].prereqs), + coreqs: formatInput(resultFromUWFLow.data.course[0].coreqs), + }; + + return result; +}; + +// Retrieve courses in range of course code +export const getSearchResults = async ( + course: string, + min: number, + max: number, +): Promise => { + const resultFromUWFLow: searchResultsFromUrl = ( + await axios.post(uwflowApiUrl, { + operationName: 'explore', + variables: { + query: course, + code_only: true, + }, + query: `query explore($query: String, $code_only: Boolean) { + search_courses(args: { query: $query, code_only: $code_only }) { + name + code + has_prereqs + } + }`, + }) + ).data; + + // If no data is found, return -1 to signal error + if (resultFromUWFLow.data.search_courses.length < 1) { + return -1; + } + + // Search results + const results = resultFromUWFLow.data.search_courses; + + // Array of courses in range [min, max] + const resultArray: searchResults[] = results.filter((result) => { + const code = result.code; + let numString = ''; + for (const char of code) { + if (!isNaN(parseInt(char))) { + numString += char; + } + } + const num = parseInt(numString, 10); + return min <= num && num <= max; + }); + + return resultArray; +};