Skip to content

Commit

Permalink
Implement /meeting-reminder command (#67)
Browse files Browse the repository at this point in the history
  • Loading branch information
QuantumManiac authored Feb 23, 2024
1 parent 6a95a1f commit 78fe2ab
Show file tree
Hide file tree
Showing 9 changed files with 146 additions and 36 deletions.
14 changes: 13 additions & 1 deletion src/classes/SlackLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export enum LogLevel {
*/
export class SlackLogger {
private static instance: SlackLogger;
slackClient: WebClient;
private slackClient: WebClient;

/**
* Singleton constructor for SlackLogger
Expand Down Expand Up @@ -125,6 +125,18 @@ export class SlackLogger {
* @returns The formatted code block content
*/
private static formatCodeBlockContent(codeBlockContent: unknown): string {
if (codeBlockContent instanceof Error) {
// Recursively get the causes of the error
let errorContent = `${codeBlockContent}`;
let cause = (codeBlockContent as Error).cause;
while (cause) {
errorContent += `\t[cause]: ${cause}`;

cause = (cause as Error)?.cause;
}
return `\`\`\`${errorContent}\`\`\``;
}

return `\`\`\`${codeBlockContent}\`\`\``;
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const app = new App({
});

// Register listeners
registerListeners(app);
registerListeners(app, auth);

// Schedule tasks
scheduleTasks(app.client, auth);
Expand Down
7 changes: 6 additions & 1 deletion src/listeners/commands/helpCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { StringIndexed } from "@slack/bolt/dist/types/helpers";

import { logCommandUsed } from "../../utils/logging";
import { postEphemeralMessage } from "../../utils/slack";
import { SlackLogger } from "../../classes/SlackLogger";

const message = `For more information, check out <https://github.com/waterloo-rocketry/minerva-rewrite/blob/main/README.md|minerva's README>.`;

Expand All @@ -22,5 +23,9 @@ export default async function helpCommandHandler({
await ack();
await logCommandUsed(command);

await postEphemeralMessage(client, command.channel_id, command.user_id, message);
try {
await postEphemeralMessage(client, command.channel_id, command.user_id, message);
} catch (error) {
SlackLogger.getInstance().error("Failed to send help message", error);
}
}
5 changes: 4 additions & 1 deletion src/listeners/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
// commands/index.ts
import { App } from "@slack/bolt";
import { OAuth2Client } from "google-auth-library";
import helpCommandHandler from "./helpCommand";
import notifyCommandHandler from "./notifyCommand";
import meetingReminderCommandHandler from "./meetingReminderCommand";

const register = (app: App): void => {
const register = (app: App, googleAuth: OAuth2Client): void => {
app.command("/help", helpCommandHandler);
app.command("/meeting_reminder", (payload) => meetingReminderCommandHandler(payload, googleAuth));
app.command("/notify", notifyCommandHandler);
};

Expand Down
54 changes: 54 additions & 0 deletions src/listeners/commands/meetingReminderCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { AllMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
import { OAuth2Client } from "google-auth-library";

import { filterEventsForChannels, getEvents, parseEvents } from "../../utils/googleCalendar";
import { getAllSlackChannels } from "../../utils/channels";
import { logCommandUsed } from "../../utils/logging";
import { postEphemeralMessage } from "../../utils/slack";
import { EventReminderType, remindUpcomingEvent } from "../../utils/eventReminders";
import { SlackLogger } from "../../classes/SlackLogger";

/**
* Handler for the /meeting_reminder command
* @param obj The arguments for the middleware
* @param obj.ack The Bolt app's `ack()` function
* @param obj.command The command payload
* @param obj.client The Bolt app client
* @param googleAuth The OAuth2Client for Google Calendar
*/
export default async function meetingReminderCommandHandler(
{ command, ack, client }: SlackCommandMiddlewareArgs & AllMiddlewareArgs,
googleAuth: OAuth2Client,
): Promise<void> {
ack();
logCommandUsed(command);

try {
const slackChannels = await getAllSlackChannels(client);
const fetchedEvents = await getEvents(googleAuth);
const events = parseEvents(fetchedEvents, slackChannels);
const eventsInChannel = filterEventsForChannels(events, [command.channel_name]);

if (eventsInChannel.length === 0) {
await postEphemeralMessage(client, command.channel_id, command.user_id, "No upcoming events in this channel.");
} else {
const soonestEvent = eventsInChannel.sort((a, b) => a.start.getTime() - b.start.getTime())[0];

const commandText = command.text.trim().toLowerCase();

await remindUpcomingEvent(
soonestEvent,
client,
commandText == "ping" ? EventReminderType.MANUAL_PING : EventReminderType.MANUAL,
);
await postEphemeralMessage(
client,
command.channel_id,
command.user_id,
"Manual reminder sent for next event in channel.",
);
}
} catch (error) {
SlackLogger.getInstance().error("Failed to send manual meeting reminder", error);
}
}
6 changes: 4 additions & 2 deletions src/listeners/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { App } from "@slack/bolt";
import { OAuth2Client } from "google-auth-library";

import actions from "./actions";
import commands from "./commands";
import events from "./events";
import messages from "./messages";
import shortcuts from "./shortcuts";
import views from "./views";

const registerListeners = (app: App): void => {
const registerListeners = (app: App, googleClient: OAuth2Client): void => {
actions.register(app);
commands.register(app);
commands.register(app, googleClient);
events.register(app);
messages.register(app);
shortcuts.register(app);
Expand Down
28 changes: 19 additions & 9 deletions src/utils/eventReminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const TIME_CHECK_INTERVAL = 1000 * 60 * 5; // 5 minutes in milliseconds
* The types of event reminders that can be sent
*/
export enum EventReminderType {
MANUAL = 1,
MANUAL_PING = 2,
FIVE_MINUTES = 1000 * 60 * 5, // 5 minutes in milliseconds
SIX_HOURS = 1000 * 60 * 60 * 6, // 6 hours in milliseconds
}
Expand Down Expand Up @@ -59,12 +61,14 @@ export function getEventReminderType(event: CalendarEvent): EventReminderType |
* Posts a reminder for the given event to the channel it is associated with
* @param event The event to post a reminder for
* @param client Slack Web API client
* @param reminderType The type of reminder to post
* @param defaultSlackChannels The default Slack channels to post reminders to. If not provided, the default channels will be fetched from the Slack API
* @param allSlackUsersInWorkspace All Slack users in the workspace. If not provided, the users will be fetched from the Slack API
*/
export async function remindUpcomingEvent(
event: CalendarEvent,
client: WebClient,
reminderType: EventReminderType | null,
defaultSlackChannels?: SlackChannel[],
allSlackUsersInWorkspace?: SlackUser[],
): Promise<void> {
Expand All @@ -73,9 +77,7 @@ export async function remindUpcomingEvent(
return;
}

const reminderType = getEventReminderType(event);

if (!reminderType) {
if (reminderType == null) {
return;
}

Expand Down Expand Up @@ -104,10 +106,15 @@ export async function remindUpcomingEvent(
allSlackUsersInWorkspace,
);

const reminderTypeString = reminderType === EventReminderType.FIVE_MINUTES ? "5 minute" : "6 hour";
const reminderTypeStrings = {
[EventReminderType.MANUAL]: "manually triggered",
[EventReminderType.MANUAL_PING]: "manually triggered (with ping)",
[EventReminderType.FIVE_MINUTES]: "5 minute",
[EventReminderType.SIX_HOURS]: "6 hour",
};

SlackLogger.getInstance().info(
`Sent ${reminderTypeString} reminder for event \`${
`Sent ${reminderTypeStrings[reminderType]} reminder for event \`${
event.title
}\` at \`${event.start.toISOString()}\` to channel \`${
event.minervaEventMetadata.channel.name
Expand Down Expand Up @@ -206,7 +213,8 @@ export async function remindUpcomingEvents(client: WebClient, events: CalendarEv
const allSlackUsersInWorkspace = await getAllSlackUsers(client);

events.forEach((event) => {
remindUpcomingEvent(event, client, defaultSlackChannels, allSlackUsersInWorkspace);
const reminderType = getEventReminderType(event);
remindUpcomingEvent(event, client, reminderType, defaultSlackChannels, allSlackUsersInWorkspace);
});
}

Expand All @@ -217,9 +225,11 @@ export async function remindUpcomingEvents(client: WebClient, events: CalendarEv
* @returns The generated reminder text
*/
export function generateEventReminderChannelText(event: CalendarEvent, reminderType: EventReminderType): string {
let message = `${reminderType == EventReminderType.FIVE_MINUTES ? "<!channel>\n" : ""}Reminder: *${
event.title
}* is occurring`;
let message = `${
reminderType == EventReminderType.FIVE_MINUTES || reminderType == EventReminderType.MANUAL_PING
? "<!channel>\n"
: ""
}Reminder: *${event.title}* is occurring`;

if (reminderType === EventReminderType.FIVE_MINUTES) {
const timeUntilEvent = event.start.getTime() - new Date().getTime();
Expand Down
31 changes: 10 additions & 21 deletions src/utils/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,7 @@ export async function postEphemeralMessage(

return res;
} catch (error) {
SlackLogger.getInstance().error(
`Failed to post ephemeral message to user \`${user}\` in channel \`${channel}\` with error:`,
error,
);
throw error;
throw Error(`Failed to post ephemeral message to user \`${user}\` in channel \`${channel}\``, { cause: error });
}
}

Expand All @@ -101,8 +97,7 @@ export async function getAllEmoji(client: WebClient): Promise<string[]> {
}
return Object.keys(result.emoji);
} catch (error) {
SlackLogger.getInstance().error(`Failed to get emojis for workspace:`, error);
throw error;
throw Error("Failed to get emojis for workspace", { cause: error });
}
}

Expand Down Expand Up @@ -141,8 +136,7 @@ export async function getAllSlackUsers(
break;
}
} catch (error) {
SlackLogger.getInstance().error(`Failed to get users from workspace:`, error);
throw error;
throw Error("Failed to get users from workspace", { cause: error });
}
}
return usersList;
Expand Down Expand Up @@ -177,8 +171,7 @@ export async function getChannelMembers(client: WebClient, channelId: string): P
}
return members.length > 0 ? members : [];
} catch (error) {
SlackLogger.getInstance().error(`Failed to get members for channel with id \`${channelId}\`:`, error);
throw error;
throw Error(`Failed to get members for channel with id \`${channelId}\``, { cause: error });
}
}

Expand Down Expand Up @@ -207,11 +200,9 @@ export function addReactionToMessage(
timestamp: timestampStr,
});
} catch (error) {
SlackLogger.getInstance().error(
`Failed to add reaction \`${emoji}\` to message \`${timestampStr}\` in \`${channel}\`:`,
error,
);
throw error;
throw Error(`Failed to add reaction \`${emoji}\` to message \`${timestampStr}\` in \`${channel}\``, {
cause: error,
});
}
}

Expand All @@ -234,11 +225,9 @@ export async function getMessagePermalink(
message_ts: timestamp,
});
} catch (error) {
SlackLogger.getInstance().error(
`Error fetching message permalink for message with timestamp \`${timestamp}\` in \`${channel}\`:`,
error,
);
throw error;
throw Error(`Failed to get permalink for message with timestamp \`${timestamp}\` in \`${channel}\``, {
cause: error,
});
}

if (res?.ok) {
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/utils/meetingReminders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,41 @@ Ways to attend:
);
});

it("should generate a manually triggered reminder with the meeting link and location provided", () => {
jest.useFakeTimers().setSystemTime(new Date("2023-01-01T00:00:00.000Z").getTime());
event.location = "Test location";
event.minervaEventMetadata = {
channel: slackChannels[0],
meetingLink: "https://example.com",
};
const result = generateEventReminderChannelText(event, EventReminderType.MANUAL);

expect(result).toBe(
`Reminder: *Test event* is occurring at *January 1st, 2023 at 1:00 AM*
<https://www.google.com/calendar/event?eid=MGJyczFiMjJuZHJjZzRnZmx0Z2c1OGRocmkgdXdhdGVybG9vLnJvY2tldHJ5LmRxxxxx|Event Details>
Ways to attend:
\t:office: In person @ Test location
\t:globe_with_meridians: Online @ https://example.com`,
);
});

it("should generate a manually triggered reminder with a ping with the meeting link and location provided", () => {
jest.useFakeTimers().setSystemTime(new Date("2023-01-01T00:00:00.000Z").getTime());
event.location = "Test location";
event.minervaEventMetadata = {
channel: slackChannels[0],
meetingLink: "https://example.com",
};
const result = generateEventReminderChannelText(event, EventReminderType.MANUAL_PING);

expect(result).toBe(`<!channel>
Reminder: *Test event* is occurring at *January 1st, 2023 at 1:00 AM*
<https://www.google.com/calendar/event?eid=MGJyczFiMjJuZHJjZzRnZmx0Z2c1OGRocmkgdXdhdGVybG9vLnJvY2tldHJ5LmRxxxxx|Event Details>
Ways to attend:
\t:office: In person @ Test location
\t:globe_with_meridians: Online @ https://example.com`);
});

it("should generate a reminder for 6 hours with no meeting link or location provided", () => {
jest.useFakeTimers().setSystemTime(new Date("2023-01-01T00:00:00.000Z").getTime());
event.minervaEventMetadata = {
Expand Down

0 comments on commit 78fe2ab

Please sign in to comment.