Skip to content
Open
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
5 changes: 1 addition & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,4 @@ ENV NAME=factotum

ENV NODE_ENV=production

# Shift schedule CSV times are parsed as server-local time
ENV TZ=America/Vancouver

CMD ["npm", "start"]
CMD ["npm", "start"]
7 changes: 0 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"@sapphire/discord.js-utilities": "^7.3.3",
"@sapphire/framework": "^5.3.6",
"@sapphire/utilities": "^3.18.2",
"csv-parse": "^7.0.0",
"discord.js": "^14.20.0",
"dotenv": "^16.5.0",
"firebase-admin": "^13.4.0"
Expand Down
111 changes: 111 additions & 0 deletions src/commands/LinkEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import BaseCommand from "@/classes/BaseCommand";
import { idHints } from "@/constants/id-hints";
import { GuildDoc } from "@/types/db/guild";
import { OrganizerMappingDoc } from "@/types/db/organizer-mapping";
import { checkMemberRoles } from "@/util/discord";
import {
getGuildDocRef,
getOrganizerMappingDocRef,
} from "@/util/nwplus-firestore";
import { normalizeEmail } from "@/util/organizer-mappings";

import { ApplyOptions } from "@sapphire/decorators";
import { Command, CommandOptionsRunTypeEnum } from "@sapphire/framework";
import { MessageFlags, SlashCommandBuilder } from "discord.js";
import { Timestamp } from "firebase-admin/firestore";

// Also excludes '/' and caps length: the email becomes a Firestore doc ID,
// which rejects path separators and IDs over 1500 bytes.
const EMAIL_REGEX = /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/;
const MAX_EMAIL_LENGTH = 320;

/**
* Links an email from the Notion shift schedule to a Discord account so shift
* reminders can mention the right person. Everything is ephemeral — mappings
* pair members with (often personal) emails, which must never be shown
* publicly.
*/
@ApplyOptions<Command.Options>({
name: "link-email",
description:
"Link the email on your Notion shifts to your Discord account for shift reminders.",
runIn: CommandOptionsRunTypeEnum.GuildText,
preconditions: ["OrganizerRoleOnly"],
})
class LinkEmail extends BaseCommand {
protected override buildCommand(builder: SlashCommandBuilder) {
return builder
.addStringOption((option) =>
option
.setName("email")
.setDescription(
"The email on your shifts in Notion (personal emails are fine)",
)
.setRequired(true),
)
.addUserOption((option) =>
option
.setName("user")
.setDescription(
"Link the email to this member instead (admins and staff only)",
),
);
}

protected override setCommandOptions() {
return {
idHints: [idHints.linkEmail],
};
}

public override async chatInputRun(
interaction: Command.ChatInputCommandInteraction,
) {
const email = normalizeEmail(interaction.options.getString("email", true));
const targetUser = interaction.options.getUser("user", false);
await interaction.deferReply({ flags: MessageFlags.Ephemeral });

if (!EMAIL_REGEX.test(email) || email.length > MAX_EMAIL_LENGTH) {
return interaction.editReply(
"That doesn't look like a valid email address.",
);
}

const guildDocData = (
await getGuildDocRef(interaction.guildId!).get()
).data() as GuildDoc;
const isAdminOrStaff = checkMemberRoles(interaction.member!, [
guildDocData.roleIds.admin,
guildDocData.roleIds.staff,
]);
if (targetUser && !isAdminOrStaff) {
return interaction.editReply(
"Only admins and staff can link an email for someone else.",
);
}
const target = targetUser ?? interaction.user;

const mappingDocRef = getOrganizerMappingDocRef(email);
const existing = (await mappingDocRef.get()).data() as
| OrganizerMappingDoc
| undefined;
// Don't reveal whose it is — that would leak another member's email link.
if (existing && existing.discordUserId !== target.id && !isAdminOrStaff) {
return interaction.editReply(
"That email is already linked to a different Discord account. Ask an admin to re-link it.",
);
}

await mappingDocRef.set({
email,
discordUserId: target.id,
linkedAt: Timestamp.now(),
} satisfies OrganizerMappingDoc);

return interaction.editReply(
`Linked ${email} to ${target}. Shift reminders will now mention ${targetUser ? "them" : "you"}.`,
);
}
}

export default LinkEmail;
170 changes: 0 additions & 170 deletions src/commands/UploadSchedule.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/constants/id-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const devIdHints = {
startTickets: "1402483611156086924",
startTrivia: "1386423290645712967",
startVerification: "1385475309876412540",
uploadSchedule: "",
linkEmail: "1527733283444756661",
};

const prodIdHints = {
Expand All @@ -28,7 +28,7 @@ const prodIdHints = {
startTickets: "1404733926160728084",
startTrivia: "1404734018066448448",
startVerification: "1404734012332965969",
uploadSchedule: "",
linkEmail: "",
};

export const idHints =
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { LogLevel, SapphireClient } from "@sapphire/framework";
import { GatewayIntentBits } from "discord.js";
import "dotenv/config";

import { shiftReminderScheduler } from "./services/shift-reminder-scheduler";
import { GuildDoc } from "./types/db/guild";
import { PronounsDoc } from "./types/db/pronouns";
import { TicketDoc } from "./types/db/ticket";
Expand Down Expand Up @@ -93,6 +94,8 @@ const initializeBot = async () => {

console.log("Finished processing all guild documents");

shiftReminderScheduler.start(client);

client.on("guildMemberAdd", async (member) => {
const guildDocRef = await getGuildDocRef(member.guild.id).get();
const guildDocData = guildDocRef.data() as GuildDoc;
Expand Down
1 change: 0 additions & 1 deletion src/interaction-handlers/VerifyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
OtherAttendeesDoc,
VerificationDoc,
} from "@/types/db/verification";
import { checkMemberRoles } from "@/util/discord";
import { getGuildDocRef, getHackathonDocRef } from "@/util/nwplus-firestore";

import { ApplyOptions } from "@sapphire/decorators";
Expand Down
Loading