Skip to content

Commit

Permalink
Merge branch 'dev' into production
Browse files Browse the repository at this point in the history
  • Loading branch information
elijaholmos committed Jan 18, 2023
2 parents 6e8c4df + 2710275 commit cef464a
Show file tree
Hide file tree
Showing 15 changed files with 170 additions and 133 deletions.
60 changes: 60 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Preface

There are a _lot_ of moving parts that go into the Halo Notification Service tool suite and this repository especially. The codebase has **not** been optimized for third-party contributors, so there will be a degree of difficulty when setting up the initial project. Those unfamiliar with programming in Node.js environments will find navigating the codebase to be exceptionally difficult.

# Setting Up

This repository is, at its core, a Discord bot running in a [Node.js](https://nodejs.org/) environment. To begin setting up the project, clone the repository and navigate to the local directory. At the time of writing, the latest version of Node 16 is being used.

[pnpm](https://pnpm.io/) is the package manager for this project. Check out the [official installation guide](https://pnpm.io/) if you do not have that set up.

In the project directory, install the npm packages by running

```cmd
pnpm i --frozen-lockfile
```

Next, let's set up the environment variables.

# Environment Variables

`./env.sample` contains a template env var file with sample variables to fill in. Copy this file and rename the copy to `.env`. We'll go in order of the env variables:

## `BOT_TOKEN`

This is the Discord bot client secret that can be used to programmatically interact with the Discord API and autonomously control a bot user. [Discord.js](https://discord.js.org/#/) has published a brief [guide](https://discordjs.guide/preparations/setting-up-a-bot-application.html#creating-your-bot) that explains how to create a bot account and obtain its token.

## `GOOGLE_APPLICATION_CREDENTIALS`

[Google Firebase](https://firebase.google.com/) is the cloud database provider for this project. Unfortunately, due to the size of this project, there is no official mock database seed that can be loaded into development environments. However, for testing purposes, a Firebase instance can still be created and the credentials downloaded locally. This [guide](https://cloud.google.com/firestore/docs/client/get-firebase) explains how to create a new Firebase project.

## `RSA_PUBLIC_KEY` & `RSA_PRIVATE_KEY`

For [FERPA](https://studentprivacy.ed.gov/ferpa#0.1_se34.1.99_11)-compliancy, all student education records are encrypted with a hybrid AES/RSA encryption algorithm. The RSA public/private keys are uniquely generated per environment, and should not be shared or reused after their initial generation. A simple Node.js script can generate these keys, base-64 encode them, and write them to the local filesystem:

```js
import { generateKeyPairSync } from 'node:crypto';
import { writeFile } from 'node:fs/promises';

const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 3072 });
await writeFile('public.pem', Buffer.from(publicKey.export({ type: 'spki', format: 'pem' })).toString('base64'));
await writeFile('private.pem', Buffer.from(privateKey.export({ type: 'pkcs8', format: 'pem' })).toString('base64'));
```

The content of these files can be directly used as values for the `RSA_PUBLIC_KEY` and `RSA_PRIVATE_KEY` environment variables.

# Local Development

For propriety, a (skeleton) mock API exists to test interactions between the Node.js app and the Halo API in an isolated environment. Unfortunately, the mock data for this mock API cannot be provided to contributors for security reasons. Examination of the `api/` directory will reveal a simple [Express.js](https://expressjs.com/) app. Close inspection of the `GatewayController` should reveal the different types of data that need to be included in the mock API, as well as where to store them. The bot will compile and initialize indepently of the mock API.

To start the Discord bot in a development environment, run the command:

```cmd
pnpm start
```

The entry point of the app is `index.js`.

# Conclusion

Apologies for the hastily-assembled contributing guide. I am a strong supporter of OSS, but I am also a student, and my time can only be split between so many things. Because the number of people that would benefit from the features of this project vastly outnumbers the number of people that would benefit from the documentation of it, I have accordingly prioritized feature-driven development.
6 changes: 4 additions & 2 deletions classes/CookieManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,12 @@ export class CookieManager {
const ref = db.ref('cookies');
ref.orderByChild('timestamp').startAt(Date.now()).on('child_added', updateHandler);
ref.on('child_changed', updateHandler);
//cookie was deleted, check to see it if was an uninstall
//cookie was deleted, make sure it was due to an uninstall
//don't want to be deleting server-side cookies accidentally
ref.on('child_removed', async (snapshot) => {
const uid = snapshot.key;
if (!(await Firebase.getFirebaseUserSnapshot(uid))?.uninstalled) return;
//ext_devices should only be zero when the user has completely uninstalled
if ((await Firebase.getFirebaseUserSnapshot(uid))?.ext_devices !== 0) return;

Logger.cookie(`${uid}'s cookie has been removed`);
clearTimeout(timeouts.get(uid)); //clear timeout if it already exists for this user
Expand Down
2 changes: 1 addition & 1 deletion classes/services/401Service.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const handle401 = async function ({ uid, msg }) {
//check if setting enabled
if (!Firebase.getUserSettingValue({ uid, setting_id: 3 })) return;
// send notification to user
const user = await bot.users.fetch(Firebase.getDiscordUid(uid));
const user = await bot.users.fetch(uid);
const msg = await bot.sendDM({
user,
embed: new EmbedBase({
Expand Down
9 changes: 3 additions & 6 deletions classes/services/AnnouncementService.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,11 @@ export class AnnouncementService {
for (const uid of Firebase.getActiveUsersInClass(announcement.courseClassId)) {
try {
// if (!Firebase.getUserSettingValue({ uid, setting_id: 0 })) continue;
const discord_uid = Firebase.getDiscordUid(uid);
const discord_user = await bot.users.fetch(discord_uid);
const discord_user = await bot.users.fetch(uid);
discord_user
.send(message)
.catch((e) =>
Logger.error(`Error sending announcement to ${discord_user.tag} (${discord_uid}): ${e}`)
);
Logger.log(`Announcement DM sent to ${discord_user.tag} (${discord_uid})`);
.catch((e) => Logger.error(`Error sending announcement to ${discord_user.tag} (${uid}): ${e}`));
Logger.log(`Announcement DM sent to ${discord_user.tag} (${uid})`);
bot.logDiscord({
embed: new EmbedBase({
title: 'Announcement Message Sent',
Expand Down
53 changes: 10 additions & 43 deletions classes/services/FirebaseService.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { ServerValue } from 'firebase-admin/database';
import { decryptCookieObject, encryptCookieObject, isValidCookieObject, Logger } from '..';
import { COOKIES } from '../../caches';
import { db } from '../../firebase';
import { CLASS_USERS_MAP, DEFAULT_SETTINGS_STORE, DISCORD_USER_MAP, USER_SETTINGS_STORE } from '../../stores';
import { CLASS_USERS_MAP, DEFAULT_SETTINGS_STORE, USER_SETTINGS_STORE } from '../../stores';
const ACTIVE_STAGES = ['PRE_START', 'CURRENT', 'POST'];

export const getActiveClasses = async function () {
Expand All @@ -32,30 +32,14 @@ export const getAllClasses = async function () {
};

/**
* @returns {string[]} array of discord uids
*/
export const getActiveDiscordUsersInClass = function (class_id) {
return process.env.NODE_ENV === 'production'
? Object.keys(CLASS_USERS_MAP.get(class_id) ?? {}).map(DISCORD_USER_MAP.get)
: ['139120967208271872'];
};

/**
* @returns {Promise<string[]>} array of HNS IDs
*/
export const getActiveUsersInClassAsync = async function (class_id) {
return Object.keys((await db.ref('class_users_map').child(class_id).get()) ?? {});
};

/**
* @returns {string[]} array of HNS IDs
* @returns {string[]} array of Discord uids
*/
export const getActiveUsersInClass = function (class_id) {
return Object.keys(CLASS_USERS_MAP.get(class_id) ?? {});
};

/**
* @param {string} uid HNS UID
* @param {string} uid discord UID
* @returns {Promise<string[]>} array of class IDs
*/
export const getAllUserClasses = async function (uid) {
Expand All @@ -64,7 +48,7 @@ export const getAllUserClasses = async function (uid) {

/**
* Get the Halo cookie object for a user
* @param {string} uid HNS UID
* @param {string} uid Discord UID
* @param {boolean} check_cache Whether the local cache should be checked first
*/
export const getUserCookie = async function (uid, check_cache = true) {
Expand Down Expand Up @@ -95,6 +79,7 @@ export const removeUserCookie = async function (uid) {

/**
* Convert a Halo UID to a Discord UID
* TODO: implement caching to improve performance
* @param {string} uid halo user id
* @returns {Promise<string | null>} discord user id
*/
Expand All @@ -104,47 +89,29 @@ export const getDiscordUidFromHaloUid = async function (uid) {
: '139120967208271872';
};

/**
* Convert a HNS UID to a Discord UID
* @param {string} uid
* @returns {string | null} discord uid, if exists in map
*/
export const getDiscordUid = function (uid) {
return process.env.NODE_ENV === 'production' ? DISCORD_USER_MAP.get(uid) : '139120967208271872';
};

/**
* Convert a Discord UID to a HNS UID
* @param {string} discord_uid
* @returns {string | null} HNS uid, if exists in map
*/
export const getHNSUid = function (discord_uid) {
return DISCORD_USER_MAP.get(discord_uid);
};

export const getFirebaseUserSnapshot = async function (uid) {
return (await db.ref('users').child(uid).once('value')).val();
};

/**
* Get all users currently using the service
* @returns {Promise<string[]>} array of HNS uids
* @returns {Promise<string[]>} array of Discord uids
*/
export const getAllActiveUsers = async function getAllActiveUsersUids() {
return Object.keys((await db.ref('users').orderByChild('uninstalled').equalTo(null).get()).val() ?? {});
return Object.keys((await db.ref('users').orderByChild('ext_devices').startAt(1).get()).val() ?? {});
};

/**
* Get all users currently using the service (with expanded information)
* @returns {Promise<object>}
*/
export const getAllActiveUsersFull = async function () {
return (await db.ref('users').orderByChild('uninstalled').equalTo(null).get()).val() ?? {};
return (await db.ref('users').orderByChild('ext_devices').startAt(1).get()).val() ?? {};
};

/**
* Retrieve a user's settings
* @param {string} uid discord-halo uid
* @param {string} uid discord uid
* @returns {object} user settings
*/
export const getUserSettings = function (uid) {
Expand All @@ -154,7 +121,7 @@ export const getUserSettings = function (uid) {
/**
* Get the user-set value associated with the `setting_id`
* @param {object} args Destructured arguments
* @param {string} args.uid discord-halo uid
* @param {string} args.uid discord uid
* @param {string | number} args.setting_id ID of setting to retieve
* @returns {any} The value of the user's setting if set, otherwise the default setting value
*/
Expand Down
6 changes: 2 additions & 4 deletions classes/services/GradeService.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,9 @@ export class GradeService {
*/
static async #publishGrade({ grade }) {
try {
const discord_uid =
Firebase.getDiscordUid(grade?.metadata?.uid) ??
(await Firebase.getDiscordUidFromHaloUid(grade.user.id));
const discord_uid = grade?.metadata?.uid ?? (await Firebase.getDiscordUidFromHaloUid(grade.user.id));
const show_overall_grade = Firebase.getUserSettingValue({
uid: Firebase.getHNSUid(discord_uid),
uid: discord_uid,
setting_id: 4,
});

Expand Down
5 changes: 2 additions & 3 deletions classes/services/HaloService.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,13 @@ export const getUserId = async function ({ cookie }) {
* @param {string} args.uid Discord UID of the user
* @returns {Promise<EmbedBase>}
*/
export const generateUserConnectionEmbed = async function ({ uid: discord_uid }) {
export const generateUserConnectionEmbed = async function ({ uid }) {
try {
const uid = Firebase.getHNSUid(discord_uid);
const cookie = await Firebase.getUserCookie(uid);

if (!(await validateCookie({ cookie }))) throw `Cookie for ${uid} failed to pass validation`;
return new EmbedBase({
description: '✅ **Your account is currently connceted to Halo**',
description: '✅ **Your account is currently connected to Halo**',
}).Success();
} catch (err) {
return new EmbedBase().ErrorDesc('Your account is currently not connected to Halo');
Expand Down
3 changes: 1 addition & 2 deletions classes/services/InboxMessageService.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ export class InboxMessageService {
static async #publishInboxMessage({ inbox_message, message }) {
try {
const discord_uid =
Firebase.getDiscordUid(inbox_message?.metadata?.uid) ??
(await Firebase.getDiscordUidFromHaloUid(inbox_message.user.id));
inbox_message?.metadata?.uid ?? (await Firebase.getDiscordUidFromHaloUid(inbox_message.user.id));
const discord_user = await bot.users.fetch(discord_uid);
discord_user
.send(message)
Expand Down
3 changes: 1 addition & 2 deletions commands/development/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ class test extends Command {

async run({ intr }) {
try {
const uid = Firebase.getHNSUid(intr.user.id);
const cookie = await Firebase.getUserCookie(uid);
const cookie = await Firebase.getUserCookie(intr.user.id);

// const feedback = await Halo.getGradeFeedback({
// cookie,
Expand Down
22 changes: 9 additions & 13 deletions events/discord/interactionCreate/acknowledgeAnnouncement.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,20 @@ export default class extends DiscordEvent {
try {
Logger.cmd(`${intr.user.tag} (${intr.user.id}) clicked ${this.name} btn with id of ${intr.customId}`);
await Halo.acknowledgePost({
cookie: await Firebase.getUserCookie(Firebase.getHNSUid(intr.user.id)),
cookie: await Firebase.getUserCookie(intr.user.id),
post_id,
});

// copy components, disable button, then update
// copy components, disable button, then update as "confirmation" to user
const components = intr.message.components;
components[0].components.find(({ customId }) => customId === intr.customId).disabled = true;
Object.assign(
components
.flatMap(({ components }) => components)
// api is weird and return customId if cached, custom_id otherwise
.find(({ custom_id, customId }) => (custom_id ?? customId) === intr.customId),
{ disabled: true, style: 'SUCCESS', label: 'Marked as Read', emoji: { name: '✅' } }
);
await intr.update({ components });

// send response to user
bot.intrReply({
intr,
ephemeral: true,
followUp: true,
embed: new EmbedBase({
description: `✅ **Announcement successfully marked as read**`,
}).Success(),
});
} catch (err) {
Logger.error(`Error with btn ${this.name} ${intr.customId}: ${err}`);
bot.intrReply({
Expand Down
22 changes: 9 additions & 13 deletions events/discord/interactionCreate/acknowledgeGrade.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,20 @@ export default class extends DiscordEvent {
try {
Logger.cmd(`${intr.user.tag} (${intr.user.id}) clicked ${this.name} btn with id of ${intr.customId}`);
await Halo.acknowledgeGrade({
cookie: await Firebase.getUserCookie(Firebase.getHNSUid(intr.user.id)),
cookie: await Firebase.getUserCookie(intr.user.id),
assessment_grade_id,
});

// copy components, disable button, then update
// copy components, disable button, then update as "confirmation" to user
const components = intr.message.components;
components[0].components.find(({ customId }) => customId === intr.customId).disabled = true;
Object.assign(
components
.flatMap(({ components }) => components)
// api is weird and return customId if cached, custom_id otherwise
.find(({ custom_id, customId }) => (custom_id ?? customId) === intr.customId),
{ disabled: true, style: 'SUCCESS', label: 'Marked as Read', emoji: { name: '✅' } }
);
await intr.update({ components });

// send response to user
bot.intrReply({
intr,
ephemeral: true,
followUp: true,
embed: new EmbedBase({
description: `✅ **Grade successfully marked as read**`,
}).Success(),
});
} catch (err) {
Logger.error(`Error with btn ${this.name} ${intr.customId}: ${err}`);
bot.intrReply({
Expand Down
Loading

0 comments on commit cef464a

Please sign in to comment.