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;
+};