diff --git a/src/discord/commands/FetchInternships.ts b/src/discord/commands/FetchInternships.ts new file mode 100644 index 0000000..2917c41 --- /dev/null +++ b/src/discord/commands/FetchInternships.ts @@ -0,0 +1,170 @@ +import { + ApplicationCommandOptionType, + CacheType, + ChatInputCommandInteraction, + EmbedBuilder, + PermissionFlagsBits, +} from 'discord.js'; +import getInternshipOppertunitiesJob from '../../jobs/fetchInternships'; +import Command from '../classes/Command'; +import DiscordClient from '../classes/DiscordClient'; +import Category from '../enums/Category'; +import fetch from 'node-fetch'; + +async function generateCompaniesText( + companies: { company: string; jobTitle: string; link: string }[], +) { + const totalCount = companies.length; + const today = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const listForModel = companies + .map((c, i) => `${i + 1}. ${c.company}: [${c.jobTitle}](<${c.link}>)`) + .join('\n'); + + const prompt = ` +${today} Internship Postings Summary +We found ${totalCount} new internships posted today! + +Here are some of our favorites +1. +2. +3. +4. +5. +6. +7. +8. +9. +10. + +Format: \${c.company}: [\${c.jobTitle}](<\${c.link}>) + +INSTRUCTIONS FOR THE MODEL: +- From the list below, select up to 10 best internships and fill items 1-10 using the exact Format above. +- Only output the summary in the exact structure shown (date line, count line, "Here are some of our favorites", numbered list up to 10, and the final line about !fetch). Do NOT add any extra explanation, commentary, or anything else. +- If fewer than 10 top picks exist, only output the items available but keep numbering starting from 1. +- ALWAYS use the count ${totalCount} in the "We found" line. + +AVAILABLE INTERNSHIPS: +${listForModel} +`.trim(); + + try { + const res = await fetch( + 'https://gemini.googleapis.com/v1/models/gemini-1-mini:generate', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.GEMINI_API_KEY}`, + }, + body: JSON.stringify({ + prompt: prompt, + max_output_tokens: 800, + temperature: 0.2, + }), + }, + ); + + const json = await res.json(); + + const text = + (json?.candidates && json.candidates[0]?.content) || + json?.output?.[0]?.content || + json?.output_text || + json?.text || + (typeof json === 'string' ? json : null); + + if (text) return String(text).trim(); + } catch (err) { + // fallback to local formatting + } + + const fallbackItems = companies + .slice(0, 10) + .map((c, i) => `${i + 1}. ${c.company}: [${c.jobTitle}](<${c.link}>)`) + .join('\n'); + return `${today} Internship Postings Summary +We found ${totalCount} new internships posted today! + +Here are some of our favorites +${fallbackItems}`; +} + +export default class Test extends Command { + constructor(client: DiscordClient) { + super(client, { + name: 'fetchinternships', + description: 'Fetch todays internships from the Simplify Repo.', + category: Category.Utilities, + options: [ + { + name: 'category', + description: 'Type of Internship', + type: ApplicationCommandOptionType.String, + required: true, + choices: [ + { name: '💻 Software Engineering Internship Roles', value: 'SWE' }, + { + name: '🤖 Data Science, AI & Machine Learning Internship Roles', + value: 'AI', + }, + { name: '📱 Product Management Internship Roles', value: 'PM' }, + { + name: '📈 Quantitative Finance Internship Roles', + value: 'QUANT', + }, + { name: '🔧 Hardware Engineering Internship Roles', value: 'HWE' }, + { name: 'All (Will take longer)', value: 'ALL' }, + ], + }, + ], + default_member_permissions: PermissionFlagsBits.UseApplicationCommands, + dm_permission: true, + cooldown: 3, + }); + } + + async Execute(interaction: ChatInputCommandInteraction) { + const category = interaction.options.getString('category', true); + await interaction.deferReply({ ephemeral: true }); + try { + const result = await getInternshipOppertunitiesJob( + this.client, + interaction.guild ?? null, + )(category); + + const companiesLength = result.companies.length; + if (companiesLength === 0) { + await interaction.followUp({ + content: `No new ${category} internships were posted on our DB today.`, + }); + return; + } + const companiesText = await generateCompaniesText(result.companies); + + // Split into embed if too long + const embed = new EmbedBuilder() + .setTitle('📋 Internship Postings Summary') + .setDescription(companiesText.substring(0, 4096)) + .setColor('#0099ff'); + + await interaction.editReply({ embeds: [embed] }); + + } catch (err: Error | any) { + if (err instanceof Error) { + await interaction.followUp({ + content: `Error fetching internships: ${err.message}`, + }); + } else { + await interaction.followUp({ + content: `Error fetching internships: ${err}`, + }); + } + } + } +} diff --git a/src/discord/events/client/Ready.ts b/src/discord/events/client/Ready.ts index 5e4bd94..a738311 100644 --- a/src/discord/events/client/Ready.ts +++ b/src/discord/events/client/Ready.ts @@ -4,6 +4,7 @@ import Event from "../../classes/Event"; import Command from "../../classes/Command"; import { RecurrenceRule, scheduleJob } from "node-schedule"; import getDiscordEventsJob from "../../../jobs/syncDiscordEventsJob"; +import getInternshipOppertunitiesJob from "../../../jobs/fetchInternships"; import Logger from "../../../utils/Logger"; import { CONFIG } from "../../.."; @@ -53,6 +54,45 @@ export default class Ready extends Event { this.client.guilds.cache.forEach(guild => { scheduleJob(rule, getDiscordEventsJob(this.client, guild)); + scheduleJob(rule, async (fireDate: Date) => { + try { + const result = await getInternshipOppertunitiesJob(this.client, guild)("ALL"); + console.log('Job executed successfully:', result); + + const companiesText = result.companies + .map((c) => `${c.company}: [${c.jobTitle}](<${c.link}>)`) + .join('\n'); + + const targetChannel = guild.channels.cache.find( + (channel) => channel.name === "opportunities-test" && channel.isTextBased() + ); + + if (!targetChannel || !targetChannel.isTextBased()) { + console.error("Target channel not found or is not text-based."); + return; + } + + if (companiesText.length === 0) { + return; + } + + if (companiesText.length < 1900) { + await (targetChannel as TextChannel).send( + `**Here are today's internships:**\n${companiesText}` + ); + } else { + const chunks = companiesText.match(/[\s\S]{1,1900}(?=\n|$)/g); // Split into chunks of max 1900 characters, breaking at newlines + if (chunks) { + await (targetChannel as TextChannel).send(`**Here are today's internships:**`); + for (const chunk of chunks) { + await (targetChannel as TextChannel).send(chunk); + } + } + } + } catch (error) { + console.error("Error executing job:", error); + } + }); }) Logger.once("setup", "Successfully set up interval sync.") @@ -64,12 +104,15 @@ export default class Ready extends Event { commands.forEach(command => { data.push({ - name: command.name, - description: command.description, - options: command.options, - default_member_permissions: command.default_member_permissions.toString(), - dm_permission: command.dm_permission, - }) + name: command.name, + description: command.description, + options: command.options, + default_member_permissions: + command.default_member_permissions !== undefined + ? command.default_member_permissions.toString() + : null, + dm_permission: command.dm_permission, + }); }) return data; diff --git a/src/jobs/fetchInternships.ts b/src/jobs/fetchInternships.ts new file mode 100644 index 0000000..ae6c781 --- /dev/null +++ b/src/jobs/fetchInternships.ts @@ -0,0 +1,289 @@ +import { + Guild, + GuildScheduledEventEntityType, + GuildScheduledEventPrivacyLevel, +} from 'discord.js'; +import DiscordClient from '../discord/classes/DiscordClient'; +import Logger from '../utils/Logger'; +import { CONFIG } from '..'; +import sendInternshipJobSummary from './sendInternshipJobSummary'; +import { exec } from 'child_process'; +import { createReadStream, writeFileSync, promises as fsPromises } from 'fs'; + +// --- Constants --- +const README_PATH = '../Summer2026-Internships/README.md'; +const SECTION_OUTPUT_PATH = '../Summer2026-Internships/section_output.md'; +const COMPANIES_OUTPUT_PATH = '../Summer2026-Internships/companies_output.md'; +let SECTION_HEADERS = ['## 🤖 Data Science, AI & Machine Learning Internship Roles']; +const NAME = 'Fetch Internship Opportunities'; + +const SECTIONS = [ + { name: 'SWE', value: '## 💻 Software Engineering Internship Roles' }, + { name: 'AI', value: '## 🤖 Data Science, AI & Machine Learning Internship Roles'}, + { name: 'PM', value: '## 📱 Product Management Internship Roles' }, + { name: 'QUANT', value: '## 📈 Quantitative Finance Internship Roles' }, + { name: 'HWE', value: '## 🔧 Hardware Engineering Internship Roles' }, + { name: 'ALL', value: '## All (Will take longer)' }, +]; + +// --- Utility Functions --- +function gitPullInternshipsRepo(): Promise { + return new Promise((resolve, reject) => { + exec( + 'cd ../Summer2026-Internships && git pull origin && cd ../CSAI-Discord-Bot', + (error, stdout, stderr) => { + if (error) { + // Try the alternative command if the first one fails + exec( + 'cd ../Summer2026-Internships && git pull origin && cd ../glassic-bot', + (altError, altStdout, altStderr) => { + if (altError) return reject(altError); + resolve(altStdout); + }, + ); + } else { + resolve(stdout); + } + }, + ); + }); +} + +function extractSectionTable(readmeContent: string, sectionHeader: string): string { + const lines = readmeContent.split('\n'); + let sectionStarted = false; + let sectionContent = ''; + + for (const line of lines) { + if (line.trim() === sectionHeader) { + sectionStarted = true; + continue; + } + if ( + sectionStarted && + !sectionContent && + line.trim().startsWith('') + ) { + sectionContent += line + '\n'; + continue; + } + if (sectionStarted && sectionContent) { + if (line.endsWith('')) break; + sectionContent += line + '\n'; + } + } + + while (sectionContent && !sectionContent.trim().endsWith('')) { + const lastNewline = sectionContent.lastIndexOf('\n'); + if (lastNewline === -1) break; + sectionContent = sectionContent.slice(0, lastNewline); + } + + return sectionContent; +} + +function cleanTableHtml(tableHtml: string): string { + // Remove unwanted columns (Location, Age) + let modified = tableHtml.replace(/
1d
\s*Location\s*<\/th>/gi, ''); + modified = modified.replace(/\s*Age\s*<\/th>/gi, ''); + + // Remove for Location and Age in each row (assumes order) + modified = modified.replace( + /(]*>[\s\S]*?(]*>[\s\S]*?<\/td>)+[\s\S]*?<\/tr>)/gi, + (match) => { + let tdMatches = [...match.matchAll(/]*>[\s\S]*?<\/td>/gi)]; + if (tdMatches.length >= 5) { + tdMatches.splice(2, 1); // Remove 3rd + tdMatches.splice(3, 1); // Remove 5th + const trStart = match.match(/^]*>/i)?.[0] || ''; + const trEnd = match.match(/<\/tr>$/i)?.[0] || ''; + return trStart + tdMatches.map((m) => m[0]).join('') + trEnd; + } + return match; + }, + ); + + // Clean up links and images in table rows + modified = modified.replace(/]*>([\s\S]*?)<\/tr>/gi, (rowMatch) => { + const tdMatches = [...rowMatch.matchAll(/]*>[\s\S]*?<\/td>/gi)]; + if (tdMatches.length === 0) return rowMatch; + + // Remove tags in non-last columns + for (let i = 0; i < tdMatches.length - 1; i++) { + tdMatches[i][0] = tdMatches[i][0].replace( + /]*>[\s\S]*?<\/a>/gi, + (aMatch) => { + return aMatch.replace(/]*>([\s\S]*?)<\/a>/i, '$1'); + }, + ); + } + // For last column, keep only first link + tdMatches[tdMatches.length - 1][0] = tdMatches[ + tdMatches.length - 1 + ][0].replace( + /(]*href="([^"]+)"[^>]*>[\s\S]*?<\/a>)/gi, + (match, aTag, href, offset, string) => { + const allLinks = [ + ...string.matchAll(/]*href="([^"]+)"[^>]*>[\s\S]*?<\/a>/gi), + ]; + if (allLinks.length > 1) { + return offset === allLinks[0].index ? allLinks[0][0] : ''; + } + return match; + }, + ); + + // Fix image src and width + tdMatches.forEach((td, idx) => { + td[0] = td[0] + .replace( + /src="https:\/\/i\.imgur\.com\/fbjwDvo\.png"/gi, + 'src="https://i.imgur.com/6cFAMUo.png"', + ) + .replace(/width="50"/gi, 'width="80"'); + }); + + const trStart = rowMatch.match(/^]*>/i)?.[0] || ''; + const trEnd = rowMatch.match(/<\/tr>$/i)?.[0] || ''; + return trStart + tdMatches.map((m) => m[0]).join('') + trEnd; + }); + + return modified; +} + +function extractCompanies( + tableHtml: string, +): { company: string; jobTitle: string; link: string }[] { + const companies: { company: string; jobTitle: string; link: string }[] = []; + const rowRegex = /]*>([\s\S]*?)<\/tr>/gi; + let match; + while ((match = rowRegex.exec(tableHtml)) !== null) { + const rowHtml = match[1]; + const tdMatches = [...rowHtml.matchAll(/]*>([\s\S]*?)<\/td>/gi)]; + if (tdMatches.length < 3) continue; + + const company = tdMatches[0][1].replace(/<[^>]+>/g, '').trim(); + let jobTitle = tdMatches[1][1].replace(/<[^>]+>/g, ''); + jobTitle = jobTitle + .replace( + /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, + '', + ) + .trim(); + if (/phd|doctor of philosophy/i.test(jobTitle)) continue; + + const linkMatch = tdMatches[tdMatches.length - 1][1].match( + /]*href="([^"]+)"[^>]*>/i, + ); + const link = linkMatch ? linkMatch[1] : ''; + + companies.push({ company, jobTitle, link }); + } + return companies; +} + +function writeOutputFiles( + tableHtml: string, + companies: { company: string; jobTitle: string; link: string }[], +) { + writeFileSync(SECTION_OUTPUT_PATH, tableHtml.trim(), { encoding: 'utf8' }); + const companiesOutput = companies + .map(({ company, jobTitle, link }) => `${company}: [${jobTitle}](<${link}>)`) + .join('\n'); + writeFileSync(COMPANIES_OUTPUT_PATH, companiesOutput, { encoding: 'utf8' }); +} + +// --- Main Function --- + +const getInternshipOppertunitiesJob = + (client: DiscordClient, guild: Guild | null) => async (section: string) => { + const whenDone = (log: string, success: boolean) => + sendInternshipJobSummary( + client, + CONFIG.discord.logs.channel_id, + NAME, + log, + ); + const logger = new Logger(NAME, client, whenDone); + + logger.start(); + + if (section) { + const matchedSection = SECTIONS.find( + (s) => s.name.toLowerCase() === section.toLowerCase(), + ); + if (matchedSection) { + if (matchedSection.name === 'ALL') { + logger.info( + `Category "ALL" selected. Fetching all internships (this may take longer).`, + ); + SECTION_HEADERS = SECTIONS.filter((s) => s.name !== 'ALL').map((s) => s.value); + } else { + SECTION_HEADERS = [matchedSection.value]; + logger.info(`Category "${matchedSection.name}" selected. Fetching relevant internships.`); + } + } else { + logger.warn( + `Invalid category "${section}" provided. Defaulting to "${SECTION_HEADERS}".`, + ); + } + } else { + logger.info( + `No category provided. Defaulting to "${SECTION_HEADERS}".`, + ); + } + + try { + if (!guild) { + logger.fail( + 'Failed to fetch guild - guild with provided ID not found?.', + ); + // Return a consistent shape so callers can safely use it + logger.end(); + return { + cleanedTable: '', + companies: [] as { + company: string; + jobTitle: string; + link: string; + }[], + }; + } + + const gitOutput = await gitPullInternshipsRepo(); + logger.info(`Git pull output: ${gitOutput}`); + + // Use fs.promises.readFile so we await completion and can return results + const readmeContent = await fsPromises.readFile(README_PATH, { + encoding: 'utf8', + }); + + let allCompanies: { company: string; jobTitle: string; link: string }[] = []; + let fullCleanedTable = ''; + for (const sectionHeader of SECTION_HEADERS) { + logger.info(`Processing section: ${sectionHeader}`); + const sectionTable = extractSectionTable(readmeContent, sectionHeader); + const cleanedTable = cleanTableHtml(sectionTable); + const companies = extractCompanies(cleanedTable); + fullCleanedTable += cleanedTable + '\n'; + allCompanies = allCompanies.concat(companies); + } + + writeOutputFiles(fullCleanedTable, allCompanies); + logger.info(`Section content written to ${SECTION_OUTPUT_PATH}`); + logger.info(`Companies list written to ${COMPANIES_OUTPUT_PATH}`); + logger.info( + `Successfully fetched and processed internship opportunities.`, + ); + logger.end(); + + return { cleanedTable: fullCleanedTable, companies: allCompanies }; + } catch (error: any) { + logger.fail(`Error executing job: ${error?.message ?? error}`); + logger.end(); + // rethrow so callers can handle the error + throw error; + } + }; + +export default getInternshipOppertunitiesJob; diff --git a/src/jobs/sendInternshipJobSummary.ts b/src/jobs/sendInternshipJobSummary.ts new file mode 100644 index 0000000..06cddde --- /dev/null +++ b/src/jobs/sendInternshipJobSummary.ts @@ -0,0 +1,62 @@ +import { CONFIG } from ".."; +import DiscordClient from "../discord/classes/DiscordClient"; +import Logger from "../utils/Logger"; + +// (your existing code, now with export) +const sendInternshipJobSummary = async ( + client: DiscordClient, + channelId: string, + situationName: string, + log: string +) => { + var targetChannel = client.channels.cache.find(channel => channel.id === channelId)!; + + if (!targetChannel) { + console.error(`[Send Discord Log Message] Could not find channel with ID ${channelId}.`); + return; + } + + const res = await client.llm.prompt( + `Below are the logs of a job that was just ran. Please review the logs and respond in the following format: + +Option 1: "SUCCESS/[MSG]" - respond this way if there were no errors. +Option 2: "FAIL/[MSG]" - respond this way if there were errors. + +Don't include the job name. Replace [MSG] with a summary of the job in this format:\ +Successfully/Unsuccessfully did job: [job name].\ +**Successes** +... bullet poinits with summaries of each success +**Warnings** +... bullet points with summaries of each warning +**Errors** +... bullet points with summaries of each error. do not include stack traces or the full JSON error, try to summarize the important parts. + +If there are no bullets for a section, add a bullet point saying "None." +${log}` + ) + .catch((err) => { console.error(`[Send Discord Log Message] Error sending prompt to LLM:\n`, JSON.stringify(err, null, 2)); }); + if (!res) return; + + let status = res.substring(0, res.indexOf("/")); + let message = res.substring(res.indexOf("/") + 1); + + let roles = status == "SUCCESS" ? CONFIG.discord.logs.success_roles : CONFIG.discord.logs.error_roles; + + console.log("------ sending final msg") + client.sendMessage(channelId, { + content: + (roles && roles.length > 0 + ? roles.map((role) => `<@&${role}>`).join(' ') + : '') + `${status == 'SUCCESS' ? '✅' : '❌'}`, + embeds: [ + { + title: `Report for ${situationName}`, + description: message, + fields: [], + }, + ], + }); + Logger.once("Send Discord Log Message", "sent message:\n" + message); +} + +export default sendInternshipJobSummary; \ No newline at end of file