Skip to content

Commit 00b83d6

Browse files
authored
Merge pull request #47 from acmucsd/asform-qr
Add dual QR code option to /checkin command for AS attendance forms
2 parents e5683f3 + 2bb5f1d commit 00b83d6

12 files changed

+12508
-3037
lines changed

package-lock.json

+9,319
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@
2525
"@discordjs/builders": "^0.6.0",
2626
"@discordjs/rest": "^0.1.1-canary.0",
2727
"@psibean/discord.js-pagination": "^4.0.0",
28-
"canvas": "^2.11.0",
28+
"canvas": "^3.1.0",
2929
"cron": "^1.8.2",
3030
"discord-api-types": "^0.23.1",
3131
"discord.js": "^13.1.0",
3232
"dotenv": "^8.2.0",
33-
"easyqrcodejs-nodejs": "^4.4.0",
34-
"gm": "^1.23.1",
33+
"easyqrcodejs-nodejs": "^4.5.2",
34+
"gm": "^1.22.0",
3535
"got": "^11.8.2",
36-
"jsonwebtoken": "^8.5.1",
36+
"jsonwebtoken": "^9.0.2",
3737
"lodash": "^4.17.21",
3838
"luxon": "^1.26.0",
3939
"node-schedule": "^2.0.0",

src/Client.ts

+4
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ export default class Client extends DiscordClient implements BotClient {
8989
if (!process.env.MATCH_ROLE_ID) {
9090
throw new BotInitializationError('Match Role ID');
9191
}
92+
if (!process.env.AS_ATTENDANCE_FORM_URL) {
93+
throw new BotInitializationError('AS Funded Event Attendance Form');
94+
}
9295
this.settings.clientID = process.env.CLIENT_ID;
9396
this.settings.token = process.env.BOT_TOKEN;
9497
this.settings.prefix = process.env.BOT_PREFIX;
@@ -102,6 +105,7 @@ export default class Client extends DiscordClient implements BotClient {
102105
this.settings.portalAPI.password = process.env.MEMBERSHIP_PORTAL_API_PASSWORD;
103106
this.settings.discordGuildIDs = JSON.parse(process.env.DISCORD_GUILD_IDS) as Array<string>;
104107
this.settings.matchRoleID = process.env.MATCH_ROLE_ID;
108+
this.settings.asAttendanceForm = process.env.AS_ATTENDANCE_FORM_URL;
105109
this.initialize().then();
106110
}
107111

File renamed without changes.

src/assets/as-background.png

6.52 KB
Loading

src/assets/as-qr-logo.png

20.6 KB
Loading
263 KB
Loading

src/commands/Checkin.ts

+116-13
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ export default class Checkin extends Command {
2323
.addBooleanOption(option =>
2424
option
2525
.setName('public')
26-
.setDescription('If true, send public embed of checking code for live events!')
26+
.setDescription('If true, send public embed of check-in code for live events!')
2727
.setRequired(false)
2828
)
2929
.addBooleanOption(option =>
3030
option.setName('widescreen').setDescription('Include a slide for the QR code.').setRequired(false)
3131
)
32+
.addBooleanOption(option =>
33+
option.setName('asform').setDescription('Generate a second QR code for AS Funding').setRequired(false)
34+
)
3235
.addStringOption(option =>
3336
option.setName('date').setDescription('The date to check for events. Use MM/DD format.').setRequired(false)
3437
)
@@ -71,6 +74,7 @@ export default class Checkin extends Command {
7174
// Get arguments. Get rid of the null types by checking them.
7275
const publicArgument = interaction.options.getBoolean('public');
7376
const widescreenArgument = interaction.options.getBoolean('widescreen');
77+
const asFormArgument = interaction.options.getBoolean('asform');
7478
const dateArgument = interaction.options.getString('date');
7579

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

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

137143
// Now we finally check the command argument.
138144
// If we just had `checkin` in our call, no arguments...
145+
const { asAttendanceForm } = this.client.settings;
139146
if (!isPublic) {
140147
const author = await this.client.users.fetch(interaction.member!.user.id);
141148
// What we need now is to construct the Payload to send for `checkin`.
142-
const privateMessage = await Checkin.getCheckinMessage(todayEvents, isPublic, needsSlide);
149+
const privateMessage = await Checkin.getCheckinMessage(
150+
todayEvents,
151+
isPublic,
152+
needsSlide,
153+
needsASForm,
154+
asAttendanceForm
155+
);
143156
await author.send(privateMessage);
144157
await super.edit(interaction, {
145158
content: 'Check your DM.',
146159
ephemeral: true,
147160
});
148161
await interaction.followUp(`**/checkin** was used privately by ${interaction.user}!`);
149162
} else {
150-
const publicMessage = await Checkin.getCheckinMessage(todayEvents, isPublic, needsSlide);
163+
const publicMessage = await Checkin.getCheckinMessage(
164+
todayEvents,
165+
isPublic,
166+
needsSlide,
167+
needsASForm,
168+
asAttendanceForm
169+
);
151170
await super.edit(interaction, publicMessage);
152171
}
153172
} catch (e) {
@@ -192,14 +211,28 @@ export default class Checkin extends Command {
192211
* Generate the QR Code for the given event and and return the Data URL for the code.
193212
* @param event Portal Event to create the QR code for.
194213
* @param expressCheckinURL URL that the QR code links to.
214+
* @param needsASForm if an AS attendance form is needed (if we used AS funding)
215+
* @param asFormFilledURL URL for the AS attendance form with prefilled fields.
216+
* @param needsSlide whether or not we're generating a widesgreen slide graphic
195217
* @returns URL of the generated QR code.
196218
*/
197-
private static async generateQRCodeURL(event: PortalEvent, expressCheckinURL: URL, needsSlide: boolean) {
219+
private static async generateQRCodeURL(
220+
event: PortalEvent,
221+
expressCheckinURL: URL,
222+
needsASForm: boolean,
223+
asFormFilledURL: URL,
224+
needsSlide: boolean
225+
) {
198226
// Doesn't need landscape QR slide. Return the QR code by itself
199227
let qrCodeDataUrl;
200228
if (needsSlide) {
201-
const eventQrCode = QR.generateQR(expressCheckinURL.toString(), '', '');
202-
qrCodeDataUrl = await this.createQRSlide(event, eventQrCode);
229+
const eventQrCode = QR.generateQR(expressCheckinURL.toString(), '', '', 'acm');
230+
if (needsASForm) {
231+
const asFormQrCode = QR.generateQR(asFormFilledURL.toString(), '', '', 'as');
232+
qrCodeDataUrl = await this.createQRSlide(event, eventQrCode, asFormQrCode);
233+
} else {
234+
qrCodeDataUrl = await this.createQRSlide(event, eventQrCode);
235+
}
203236
} else {
204237
const eventQrCode = QR.generateQR(
205238
expressCheckinURL.toString(),
@@ -216,9 +249,10 @@ export default class Checkin extends Command {
216249
* Creates a slide with the given QR Code and returns its URL.
217250
* @param event Portal Event to create the slide for.
218251
* @param eventQrCode QR Code for the event.
252+
* @param asFormQrCode Prefilled QR Code for AS Funding Form.
219253
* @returns URL of the generated slide.
220254
*/
221-
private static async createQRSlide(event: PortalEvent, eventQrCode: string) {
255+
private static async createQRSlide(event: PortalEvent, eventQrCode: string, asFormQrCode?: string) {
222256
/**
223257
* Rescales the font; makes the font size smaller if the text is longer
224258
* and bigger if the text is shorter.
@@ -241,9 +275,68 @@ export default class Checkin extends Command {
241275

242276
// Creating slide with Canvas
243277
// Helpful resource: https://blog.logrocket.com/creating-saving-images-node-canvas/
244-
const slide = createCanvas(1920, 1080);
278+
const slide = createCanvas(1920, 1280);
245279
const context = slide.getContext('2d');
246-
context.fillRect(0, 0, 1920, 1080);
280+
context.fillRect(0, 0, 1920, 1280);
281+
282+
// AS attendance form and ACM portal checkin both needed — use dual layout
283+
if (typeof asFormQrCode !== 'undefined' && asFormQrCode) {
284+
// Draw background
285+
const background = await loadImage('./src/assets/dual-qr-slide-background.png');
286+
context.drawImage(background, 0, 0, 1920, 1280);
287+
288+
// Draw QR code
289+
// Tilting the slide 45 degrees before adding QR code
290+
const angleInRadians = Math.PI / 4;
291+
context.rotate(angleInRadians);
292+
const qrImg = await loadImage(await eventQrCode);
293+
const asQrImg = await loadImage(await asFormQrCode);
294+
context.drawImage(qrImg, 1195, -790, 400, 400);
295+
context.drawImage(asQrImg, 535, -130, 400, 400);
296+
context.rotate(-1 * angleInRadians);
297+
298+
// Everything starting here has a shadow
299+
context.shadowColor = '#00000040';
300+
context.shadowBlur = 4;
301+
context.shadowOffsetY = 4;
302+
303+
// Event title
304+
const title =
305+
event.title.substring(0, 36) === event.title ? event.title : event.title.substring(0, 36).concat('...');
306+
const titleSize = rescaleFont(title.length, 8, 70);
307+
context.textAlign = 'center';
308+
context.font = `${titleSize}pt 'DM Sans'`;
309+
context.fillText(title, 480, 1150);
310+
311+
// Everything starting here has a shadow
312+
context.shadowColor = '#00000040';
313+
context.shadowBlur = 6.5;
314+
context.shadowOffsetY = 6.5;
315+
316+
// Code
317+
const checkinCode = event.attendanceCode;
318+
const checkinSize = rescaleFont(checkinCode.length, 30, 70);
319+
context.fillStyle = '#ffffff';
320+
context.font = `${checkinSize}pt 'DM Sans'`;
321+
const textMetrics = context.measureText(checkinCode);
322+
let codeWidth = textMetrics.actualBoundingBoxLeft + textMetrics.actualBoundingBoxRight;
323+
// Add 120 for padding on left and right side
324+
codeWidth += 120;
325+
context.fillStyle = '#70BAFF';
326+
context.beginPath();
327+
// roundRect parameters: x, y, width, height, radius
328+
context.roundRect(1410 - codeWidth / 2, 930, codeWidth, 115, 20);
329+
context.fill();
330+
context.shadowOffsetY = 6.62;
331+
context.font = `${checkinSize}pt 'DM Sans'`;
332+
context.fillStyle = '#fff';
333+
context.fillText(checkinCode, 1410, 1010);
334+
335+
// Get the Data URL of the image (base-64 encoded string of image).
336+
// Easier to attach than saving files.
337+
return slide.toDataURL();
338+
}
339+
// Only ACM portal checkin needed
247340

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

297390
// Get the Data URL of the image (base-64 encoded string of image).
298391
// Easier to attach than saving files.
299-
const qrCodeDataUrl = await slide.toDataURL();
300-
return qrCodeDataUrl;
392+
return slide.toDataURL();
301393
}
302394

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

436+
const asFormFilledURL = new URL(asAttendanceForm + event.title.replace(' ', '+'));
437+
// +'&entry.570464428='+event.foodItems.replace(' ', '+') — for food items
438+
342439
// Add the Event's title and make it a hyperlink to the express check-in URL.
343440
description.push(`*[${event.title}](${expressCheckinURL})*`);
344441
// Add the check-in code for those who want to copy-paste it.
@@ -347,7 +444,13 @@ export default class Checkin extends Command {
347444
description.push('\n');
348445

349446
try {
350-
const qrCodeDataUrl = await this.generateQRCodeURL(event, expressCheckinURL, needsSlide);
447+
const qrCodeDataUrl = await this.generateQRCodeURL(
448+
event,
449+
expressCheckinURL,
450+
needsASForm,
451+
asFormFilledURL,
452+
needsSlide
453+
);
351454
// Do some Discord.js shenanigans to generate an attachment from the image.
352455
// Apparently, the Data URL MIME type of an image needs to be removed before given to
353456
// Discord.js. Probably because the base64 encode is enough,

src/commands/QR.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,18 @@ export default class QR extends Command {
4545
* @param data The content to put in the QR code.
4646
* @param title event name
4747
* @param subtitle event description
48+
* @param org whether or not the ACM or AS relevant graphics should be used, default to ACM
4849
* @returns newly generated QR code url
4950
*/
50-
public static generateQR(data: string, title: string, subtitle: string): string {
51+
public static generateQR(data: string, title: string, subtitle: string, org: string = 'acm'): string {
5152
return new QRCode({
5253
text: data,
5354
colorDark: '#000000',
5455
colorLight: 'rgba(0,0,0,0)',
5556
correctLevel: QRCode.CorrectLevel.H,
56-
logo: 'src/assets/acm-qr-logo.png',
57+
logo: `src/assets/${org}-qr-logo.png`,
5758
logoBackgroundTransparent: false,
58-
backgroundImage: 'src/assets/background.png',
59+
backgroundImage: `src/assets/${org}-background.png`,
5960
quietZone: 40,
6061
title: title.substring(0, 36) === title ? title : title.substring(0, 36).concat('...'),
6162
titleTop: -20,

src/config/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ export default {
4141
},
4242
discordGuildIDs: [],
4343
matchRoleID: '',
44+
asAttendanceForm: '',
4445
} as BotSettings;

src/types/bot/Bot.ts

+5
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ export interface BotSettings {
140140
* ID for the role we use to match members in our /match command.
141141
*/
142142
matchRoleID: string;
143+
144+
/**
145+
* Link for the current AS attendance form
146+
*/
147+
asAttendanceForm: string;
143148
}
144149

145150
/**

0 commit comments

Comments
 (0)