Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9,319 changes: 9,319 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
"@discordjs/builders": "^0.6.0",
"@discordjs/rest": "^0.1.1-canary.0",
"@psibean/discord.js-pagination": "^4.0.0",
"canvas": "^2.11.0",
"canvas": "^3.1.0",
"cron": "^1.8.2",
"discord-api-types": "^0.23.1",
"discord.js": "^13.1.0",
"dotenv": "^8.2.0",
"easyqrcodejs-nodejs": "^4.4.0",
"gm": "^1.23.1",
"easyqrcodejs-nodejs": "^4.5.2",
"gm": "^1.22.0",
"got": "^11.8.2",
"jsonwebtoken": "^8.5.1",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"luxon": "^1.26.0",
"node-schedule": "^2.0.0",
Expand Down
4 changes: 4 additions & 0 deletions src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export default class Client extends DiscordClient implements BotClient {
if (!process.env.MATCH_ROLE_ID) {
throw new BotInitializationError('Match Role ID');
}
if (!process.env.AS_ATTENDANCE_FORM_URL) {
throw new BotInitializationError('AS Funded Event Attendance Form');
}
this.settings.clientID = process.env.CLIENT_ID;
this.settings.token = process.env.BOT_TOKEN;
this.settings.prefix = process.env.BOT_PREFIX;
Expand All @@ -102,6 +105,7 @@ export default class Client extends DiscordClient implements BotClient {
this.settings.portalAPI.password = process.env.MEMBERSHIP_PORTAL_API_PASSWORD;
this.settings.discordGuildIDs = JSON.parse(process.env.DISCORD_GUILD_IDS) as Array<string>;
this.settings.matchRoleID = process.env.MATCH_ROLE_ID;
this.settings.asAttendanceForm = process.env.AS_ATTENDANCE_FORM_URL;
this.initialize().then();
}

Expand Down
File renamed without changes
Binary file added src/assets/as-background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/as-qr-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/dual-qr-slide-background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
129 changes: 116 additions & 13 deletions src/commands/Checkin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ export default class Checkin extends Command {
.addBooleanOption(option =>
option
.setName('public')
.setDescription('If true, send public embed of checking code for live events!')
.setDescription('If true, send public embed of check-in code for live events!')
.setRequired(false)
)
.addBooleanOption(option =>
option.setName('widescreen').setDescription('Include a slide for the QR code.').setRequired(false)
)
.addBooleanOption(option =>
option.setName('asform').setDescription('Generate a second QR code for AS Funding').setRequired(false)
)
.addStringOption(option =>
option.setName('date').setDescription('The date to check for events. Use MM/DD format.').setRequired(false)
)
Expand Down Expand Up @@ -71,6 +74,7 @@ export default class Checkin extends Command {
// Get arguments. Get rid of the null types by checking them.
const publicArgument = interaction.options.getBoolean('public');
const widescreenArgument = interaction.options.getBoolean('widescreen');
const asFormArgument = interaction.options.getBoolean('asform');
const dateArgument = interaction.options.getString('date');

// Regex to match dates in the format of MM/DD(/YYYY) or MM-DD(-YYYY).
Expand Down Expand Up @@ -101,6 +105,8 @@ export default class Checkin extends Command {
const isPublic = publicArgument !== null ? publicArgument : false;
// By default, we want to include the slide.
const needsSlide = widescreenArgument !== null ? widescreenArgument : true;
// By default, we want to generate the dual AS Form
const needsASForm = asFormArgument !== null ? asFormArgument : true;

// Defer the reply ephemerally only if it's a private command call.
await super.defer(interaction, !isPublic);
Expand Down Expand Up @@ -136,18 +142,31 @@ export default class Checkin extends Command {

// Now we finally check the command argument.
// If we just had `checkin` in our call, no arguments...
const { asAttendanceForm } = this.client.settings;
if (!isPublic) {
const author = await this.client.users.fetch(interaction.member!.user.id);
// What we need now is to construct the Payload to send for `checkin`.
const privateMessage = await Checkin.getCheckinMessage(todayEvents, isPublic, needsSlide);
const privateMessage = await Checkin.getCheckinMessage(
todayEvents,
isPublic,
needsSlide,
needsASForm,
asAttendanceForm
);
await author.send(privateMessage);
await super.edit(interaction, {
content: 'Check your DM.',
ephemeral: true,
});
await interaction.followUp(`**/checkin** was used privately by ${interaction.user}!`);
} else {
const publicMessage = await Checkin.getCheckinMessage(todayEvents, isPublic, needsSlide);
const publicMessage = await Checkin.getCheckinMessage(
todayEvents,
isPublic,
needsSlide,
needsASForm,
asAttendanceForm
);
await super.edit(interaction, publicMessage);
}
} catch (e) {
Expand Down Expand Up @@ -192,14 +211,28 @@ export default class Checkin extends Command {
* Generate the QR Code for the given event and and return the Data URL for the code.
* @param event Portal Event to create the QR code for.
* @param expressCheckinURL URL that the QR code links to.
* @param needsASForm if an AS attendance form is needed (if we used AS funding)
* @param asFormFilledURL URL for the AS attendance form with prefilled fields.
* @param needsSlide whether or not we're generating a widesgreen slide graphic
* @returns URL of the generated QR code.
*/
private static async generateQRCodeURL(event: PortalEvent, expressCheckinURL: URL, needsSlide: boolean) {
private static async generateQRCodeURL(
event: PortalEvent,
expressCheckinURL: URL,
needsASForm: boolean,
asFormFilledURL: URL,
needsSlide: boolean
) {
// Doesn't need landscape QR slide. Return the QR code by itself
let qrCodeDataUrl;
if (needsSlide) {
const eventQrCode = QR.generateQR(expressCheckinURL.toString(), '', '');
qrCodeDataUrl = await this.createQRSlide(event, eventQrCode);
const eventQrCode = QR.generateQR(expressCheckinURL.toString(), '', '', 'acm');
if (needsASForm) {
const asFormQrCode = QR.generateQR(asFormFilledURL.toString(), '', '', 'as');
qrCodeDataUrl = await this.createQRSlide(event, eventQrCode, asFormQrCode);
} else {
qrCodeDataUrl = await this.createQRSlide(event, eventQrCode);
}
} else {
const eventQrCode = QR.generateQR(
expressCheckinURL.toString(),
Expand All @@ -216,9 +249,10 @@ export default class Checkin extends Command {
* Creates a slide with the given QR Code and returns its URL.
* @param event Portal Event to create the slide for.
* @param eventQrCode QR Code for the event.
* @param asFormQrCode Prefilled QR Code for AS Funding Form.
* @returns URL of the generated slide.
*/
private static async createQRSlide(event: PortalEvent, eventQrCode: string) {
private static async createQRSlide(event: PortalEvent, eventQrCode: string, asFormQrCode?: string) {
/**
* Rescales the font; makes the font size smaller if the text is longer
* and bigger if the text is shorter.
Expand All @@ -241,9 +275,68 @@ export default class Checkin extends Command {

// Creating slide with Canvas
// Helpful resource: https://blog.logrocket.com/creating-saving-images-node-canvas/
const slide = createCanvas(1920, 1080);
const slide = createCanvas(1920, 1280);
const context = slide.getContext('2d');
context.fillRect(0, 0, 1920, 1080);
context.fillRect(0, 0, 1920, 1280);

// AS attendance form and ACM portal checkin both needed — use dual layout
if (typeof asFormQrCode !== 'undefined' && asFormQrCode) {
// Draw background
const background = await loadImage('./src/assets/dual-qr-slide-background.png');
context.drawImage(background, 0, 0, 1920, 1280);

// Draw QR code
// Tilting the slide 45 degrees before adding QR code
const angleInRadians = Math.PI / 4;
context.rotate(angleInRadians);
const qrImg = await loadImage(await eventQrCode);
const asQrImg = await loadImage(await asFormQrCode);
context.drawImage(qrImg, 1195, -790, 400, 400);
context.drawImage(asQrImg, 535, -130, 400, 400);
context.rotate(-1 * angleInRadians);

// Everything starting here has a shadow
context.shadowColor = '#00000040';
context.shadowBlur = 4;
context.shadowOffsetY = 4;

// Event title
const title =
event.title.substring(0, 36) === event.title ? event.title : event.title.substring(0, 36).concat('...');
const titleSize = rescaleFont(title.length, 8, 70);
context.textAlign = 'center';
context.font = `${titleSize}pt 'DM Sans'`;
context.fillText(title, 480, 1150);

// Everything starting here has a shadow
context.shadowColor = '#00000040';
context.shadowBlur = 6.5;
context.shadowOffsetY = 6.5;

// Code
const checkinCode = event.attendanceCode;
const checkinSize = rescaleFont(checkinCode.length, 30, 70);
context.fillStyle = '#ffffff';
context.font = `${checkinSize}pt 'DM Sans'`;
const textMetrics = context.measureText(checkinCode);
let codeWidth = textMetrics.actualBoundingBoxLeft + textMetrics.actualBoundingBoxRight;
// Add 120 for padding on left and right side
codeWidth += 120;
context.fillStyle = '#70BAFF';
context.beginPath();
// roundRect parameters: x, y, width, height, radius
context.roundRect(1410 - codeWidth / 2, 930, codeWidth, 115, 20);
context.fill();
context.shadowOffsetY = 6.62;
context.font = `${checkinSize}pt 'DM Sans'`;
context.fillStyle = '#fff';
context.fillText(checkinCode, 1410, 1010);

// Get the Data URL of the image (base-64 encoded string of image).
// Easier to attach than saving files.
return slide.toDataURL();
}
// Only ACM portal checkin needed

// Draw background
const background = await loadImage('./src/assets/qr-slide-background.png');
Expand Down Expand Up @@ -296,8 +389,7 @@ export default class Checkin extends Command {

// Get the Data URL of the image (base-64 encoded string of image).
// Easier to attach than saving files.
const qrCodeDataUrl = await slide.toDataURL();
return qrCodeDataUrl;
return slide.toDataURL();
}

/**
Expand All @@ -319,7 +411,9 @@ export default class Checkin extends Command {
private static async getCheckinMessage(
events: PortalEvent[],
isPublic: boolean,
needsSlide: boolean
needsSlide: boolean,
needsASForm: boolean,
asAttendanceForm: string
): Promise<InteractionPayload> {
// This method became very complicated very quickly, so we'll break this down.
// Create arrays to store our payload contents temporarily. We'll put this in our embed
Expand All @@ -339,6 +433,9 @@ export default class Checkin extends Command {
const expressCheckinURL = new URL('https://members.acmucsd.com/checkin');
expressCheckinURL.searchParams.set('code', event.attendanceCode);

const asFormFilledURL = new URL(asAttendanceForm + event.title.replace(' ', '+'));
// +'&entry.570464428='+event.foodItems.replace(' ', '+') — for food items

// Add the Event's title and make it a hyperlink to the express check-in URL.
description.push(`*[${event.title}](${expressCheckinURL})*`);
// Add the check-in code for those who want to copy-paste it.
Expand All @@ -347,7 +444,13 @@ export default class Checkin extends Command {
description.push('\n');

try {
const qrCodeDataUrl = await this.generateQRCodeURL(event, expressCheckinURL, needsSlide);
const qrCodeDataUrl = await this.generateQRCodeURL(
event,
expressCheckinURL,
needsASForm,
asFormFilledURL,
needsSlide
);
// Do some Discord.js shenanigans to generate an attachment from the image.
// Apparently, the Data URL MIME type of an image needs to be removed before given to
// Discord.js. Probably because the base64 encode is enough,
Expand Down
7 changes: 4 additions & 3 deletions src/commands/QR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,18 @@ export default class QR extends Command {
* @param data The content to put in the QR code.
* @param title event name
* @param subtitle event description
* @param org whether or not the ACM or AS relevant graphics should be used, default to ACM
* @returns newly generated QR code url
*/
public static generateQR(data: string, title: string, subtitle: string): string {
public static generateQR(data: string, title: string, subtitle: string, org: string = 'acm'): string {
return new QRCode({
text: data,
colorDark: '#000000',
colorLight: 'rgba(0,0,0,0)',
correctLevel: QRCode.CorrectLevel.H,
logo: 'src/assets/acm-qr-logo.png',
logo: `src/assets/${org}-qr-logo.png`,
logoBackgroundTransparent: false,
backgroundImage: 'src/assets/background.png',
backgroundImage: `src/assets/${org}-background.png`,
quietZone: 40,
title: title.substring(0, 36) === title ? title : title.substring(0, 36).concat('...'),
titleTop: -20,
Expand Down
1 change: 1 addition & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ export default {
},
discordGuildIDs: [],
matchRoleID: '',
asAttendanceForm: '',
} as BotSettings;
5 changes: 5 additions & 0 deletions src/types/bot/Bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ export interface BotSettings {
* ID for the role we use to match members in our /match command.
*/
matchRoleID: string;

/**
* Link for the current AS attendance form
*/
asAttendanceForm: string;
}

/**
Expand Down
Loading