Skip to content

Commit d8a95a4

Browse files
committed
feat: brown availability course tracking
1 parent 6838762 commit d8a95a4

5 files changed

Lines changed: 246 additions & 1 deletion

File tree

prisma/schema.prisma

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,9 @@ model Ao3WorkSnapshot {
3232
Ao3Feeds Ao3Feeds? @relation(fields: [ao3FeedsId], references: [id])
3333
ao3FeedsId String? @db.ObjectId
3434
}
35+
36+
model Course {
37+
id String @id @default(auto()) @map("_id") @db.ObjectId
38+
crn Int @unique
39+
ping String
40+
}

src/bot/commands/track-course.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
ChatInputCommandInteraction,
3+
MessageFlags,
4+
SlashCommandBuilder,
5+
} from 'discord.js';
6+
import emojis from '../constants/emojis';
7+
import DataService from '../../services/DataService';
8+
9+
export const data = new SlashCommandBuilder()
10+
.setName('track-course')
11+
.setDescription('Tracks a Brown course, waiting for its availability.!')
12+
.addIntegerOption(option =>
13+
option
14+
.setName('crn')
15+
.setDescription('The CRN of the course to track.')
16+
.setRequired(true)
17+
);
18+
19+
export async function execute(interaction: ChatInputCommandInteraction) {
20+
const crn = interaction.options.getInteger('crn', true);
21+
22+
try {
23+
await DataService.addCourse(crn, interaction.user.id);
24+
25+
await interaction.reply({
26+
content: `${emojis.BOT.LEAF} Now tracking course with CRN **${crn}** for availability!`,
27+
flags: MessageFlags.Ephemeral,
28+
});
29+
} catch (error) {
30+
console.error('Error adding course:', error);
31+
await interaction.reply({
32+
content: `${emojis.BOT.UPDATE} Failed to track course with CRN **${crn}**. It may already be tracked.`,
33+
flags: MessageFlags.Ephemeral,
34+
});
35+
}
36+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import axios from 'axios';
2+
import * as cheerio from 'cheerio';
3+
import {
4+
Client,
5+
TextChannel,
6+
ButtonBuilder,
7+
ActionRowBuilder,
8+
ButtonStyle,
9+
ComponentType,
10+
Interaction,
11+
} from 'discord.js';
12+
import { Job } from '../types/job';
13+
import DataService from '../services/DataService';
14+
import emojis from '../bot/constants/emojis';
15+
16+
export const checkCourseAvailability: Job = {
17+
name: 'check-course-availability',
18+
enabled: true,
19+
schedule: '*/30 * * * *',
20+
onStart: true,
21+
async action(client: Client) {
22+
const courses = await DataService.getCourses();
23+
24+
console.log(courses);
25+
26+
for (const course of courses) {
27+
try {
28+
console.log(course);
29+
30+
const res = await axios.get(
31+
'https://cab.brown.edu/api/?page=fose&route=details',
32+
{
33+
data: { key: `crn:${course.crn}`, srcdb: '999999' },
34+
headers: {
35+
'User-Agent':
36+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
37+
},
38+
}
39+
);
40+
41+
console.log(res.data);
42+
43+
const courseTitle = res.data['title'];
44+
const courseCode = res.data['code'];
45+
const courseSection = res.data['section'];
46+
const courseTerm = res.data['srcdb'];
47+
48+
const seatsHTML = res.data['seats'];
49+
50+
let maxSeats: string = '0';
51+
let seatsAvail: string = '0';
52+
53+
// console.log(res.data);
54+
55+
if (seatsHTML) {
56+
const $ = cheerio.load(seatsHTML);
57+
maxSeats = $('.seats_max').text();
58+
console.log(maxSeats);
59+
seatsAvail = $('.seats_avail').text();
60+
console.log(seatsAvail);
61+
}
62+
63+
console.log(
64+
`Course ${courseCode} - ${courseSection}: ${seatsAvail} seats available (max ${maxSeats})`
65+
);
66+
67+
if (!seatsHTML || parseInt(seatsAvail) > 0) {
68+
const channelId = process.env.COURSE_ALERTS_CHANNEL;
69+
if (!channelId) {
70+
console.error('COURSE_ALERTS_CHANNEL is not set.');
71+
return;
72+
}
73+
74+
const channel = client.channels.cache.get(
75+
channelId
76+
) as TextChannel;
77+
78+
if (!channel) {
79+
console.error('Course alerts channel not found.');
80+
return;
81+
}
82+
83+
const removeButton = new ButtonBuilder()
84+
.setCustomId(`remove_course:${course.crn}`)
85+
.setLabel('Remove')
86+
.setStyle(ButtonStyle.Danger);
87+
88+
const linkButton = new ButtonBuilder()
89+
.setLabel('View Course')
90+
.setStyle(ButtonStyle.Link)
91+
.setURL(
92+
encodeURI(
93+
`https://cab.brown.edu/?keyword=${courseCode}&srcdb=${courseTerm}`
94+
)
95+
);
96+
97+
const row =
98+
new ActionRowBuilder<ButtonBuilder>().addComponents(
99+
removeButton,
100+
linkButton
101+
);
102+
103+
let content: string;
104+
105+
if (!seatsHTML) {
106+
content = `${emojis.BOT.UPDATE} **Course Available!**\n**\` ${courseCode} \`**\` ${courseSection} \` *${courseTitle}*\n> Course cap has been removed.\n<@${course.ping}>`;
107+
} else {
108+
content = `${emojis.BOT.UPDATE} **Course Available!**\n**\` ${courseCode} \`**\` ${courseSection} \` *${courseTitle}*\n> Availability: **${seatsAvail} seats** (max. ${maxSeats})\n<@${course.ping}>`;
109+
}
110+
111+
const message = await channel.send({
112+
content,
113+
components: [row],
114+
});
115+
116+
const collector = message.createMessageComponentCollector({
117+
componentType: ComponentType.Button,
118+
time: 5 * 60 * 1000,
119+
});
120+
121+
collector.on(
122+
'collect',
123+
async (interaction: Interaction) => {
124+
if (!interaction.isButton()) return;
125+
126+
if (
127+
interaction.customId ===
128+
`remove_course:${course.crn}`
129+
) {
130+
if (
131+
course.ping &&
132+
interaction.user.id !== String(course.ping)
133+
) {
134+
try {
135+
await interaction.reply({
136+
content: `Only <@${course.ping}> can remove this alert.`,
137+
ephemeral: true,
138+
});
139+
} catch (err) {
140+
console.error(
141+
'Failed to send permission denial reply:',
142+
err
143+
);
144+
}
145+
return;
146+
}
147+
try {
148+
await interaction.deferReply({
149+
ephemeral: true,
150+
});
151+
await DataService.removeCourse(course.crn);
152+
await interaction.editReply({
153+
content: 'Course removed from alerts.',
154+
});
155+
collector.stop();
156+
} catch (err) {
157+
console.error(
158+
'Error removing course:',
159+
err
160+
);
161+
if (!interaction.replied) {
162+
await interaction.reply({
163+
content: 'Failed to remove course.',
164+
ephemeral: true,
165+
});
166+
} else {
167+
await interaction.editReply(
168+
'Failed to remove course.'
169+
);
170+
}
171+
}
172+
}
173+
}
174+
);
175+
}
176+
} catch (error) {
177+
console.error(`Error checking course ${course.crn}:`, error);
178+
}
179+
}
180+
},
181+
};

src/jobs/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { checkCourseAvailability } from './check-course-availability';
12
import { checkFeeds } from './check-feeds';
23

3-
export default [checkFeeds];
4+
export default [checkFeeds, checkCourseAvailability];

src/services/DataService.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ const prisma = new PrismaClient();
55
class DataService {
66
static db: PrismaClient = prisma;
77

8+
static async getCourses() {
9+
return await this.db.course.findMany();
10+
}
11+
12+
static async addCourse(crn: number, ping: string) {
13+
return await this.db.course.create({
14+
data: {
15+
crn: crn,
16+
ping: ping,
17+
},
18+
});
19+
}
20+
21+
static async removeCourse(crn: number) {
22+
return await this.db.course.deleteMany({
23+
where: {
24+
crn: crn,
25+
},
26+
});
27+
}
28+
829
static async getAllFeeds() {
930
return await this.db.ao3Feeds.findMany();
1031
}

0 commit comments

Comments
 (0)