Skip to content

Commit

Permalink
Implement /notify command (#69)
Browse files Browse the repository at this point in the history
  • Loading branch information
QuantumManiac authored Feb 23, 2024
1 parent bf69ce3 commit 6a95a1f
Show file tree
Hide file tree
Showing 9 changed files with 468 additions and 9 deletions.
5 changes: 4 additions & 1 deletion src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import SlackChannel from "../classes/SlackChannel";
/**
* The "default" slack channels for use in sending DM reminders to single-channel guests
*/
export const defaultSlackChannels =
export const defaultSlackChannelNames =
environment == "production"
? [
"general",
Expand All @@ -25,3 +25,6 @@ export const loggingChannel =
environment == "production"
? new SlackChannel("minerva-log", "C016AAGP83F")
: new SlackChannel("minerva-log", "C015FSK7FQE");

export const slackWorkspaceUrl =
environment == "production" ? "https://waterloorocketry.slack.com" : "https://waterloorocketrydev.slack.com";
20 changes: 17 additions & 3 deletions src/listeners/commands/helpCommand.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { Middleware, SlackCommandMiddlewareArgs } from "@slack/bolt";
import { SlackCommandMiddlewareArgs, AllMiddlewareArgs } from "@slack/bolt";
import { StringIndexed } from "@slack/bolt/dist/types/helpers";

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

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

export const helpCommandHandler: Middleware<SlackCommandMiddlewareArgs> = async ({ command, ack, client }) => {
/**
* Handles the /help 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
* @returns A promise of void
*/
export default async function helpCommandHandler({
ack,
command,
client,
}: SlackCommandMiddlewareArgs & AllMiddlewareArgs<StringIndexed>): Promise<void> {
await ack();
await logCommandUsed(command);

await postEphemeralMessage(client, command.channel_id, command.user_id, message);
};
}
5 changes: 3 additions & 2 deletions src/listeners/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// commands/index.ts
import { App } from "@slack/bolt";
import { helpCommandHandler } from "./helpCommand";
import helpCommandHandler from "./helpCommand";
import notifyCommandHandler from "./notifyCommand";

const register = (app: App): void => {
app.command("/help", helpCommandHandler);
// Other command registrations would go here
app.command("/notify", notifyCommandHandler);
};

export default { register };
185 changes: 185 additions & 0 deletions src/listeners/commands/notifyCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { AllMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";

import { logCommandUsed } from "../../utils/logging";
import { postEphemeralMessage, postMessage } from "../../utils/slack";
import SlackChannel from "../../classes/SlackChannel";
import {
extractChannelIdFromMessageLink,
getDefaultSlackChannels,
parseEscapedSlashCommandChannel,
} from "../../utils/channels";
import { postMessageToSingleChannelGuestsInChannels } from "../../utils/users";
import { slackWorkspaceUrl } from "../../common/constants";
import { SlackLogger } from "../../classes/SlackLogger";
import { loggingChannel } from "../../common/constants";
import ObjectSet from "../../classes/ObjectSet";

export enum NotifyType {
CHANNEL,
CHANNEL_PING,
DM_SINGLE_CHANNEL_GUESTS,
}

export type NotifyParameters = {
messageUrl: string;
includeDefaultChannels: boolean;
notifyType: NotifyType;
channels: SlackChannel[];
};

/**
* Handles the /notify 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
* @returns A promise of void
*/
export default async function notifyCommandHandler({
command,
ack,
client,
}: SlackCommandMiddlewareArgs & AllMiddlewareArgs): Promise<void> {
await ack();
await logCommandUsed(command);

let notifyParams: NotifyParameters;

try {
notifyParams = parseNotifyCommand(command.text);
} catch (e) {
if (e instanceof Error) {
await postEphemeralMessage(client, command.channel_id, command.user_id, e.message);
} else {
await postEphemeralMessage(client, command.channel_id, command.user_id, "An unknown error occurred.");
}

return;
}

const { channels, includeDefaultChannels, messageUrl, notifyType } = notifyParams;
const channelSet = new ObjectSet<SlackChannel>((c) => c.id);

if (includeDefaultChannels) {
// When default is specified, we filter out the channel that the message is in
const messageChannelId = extractChannelIdFromMessageLink(messageUrl);
const channelsToAdd = (await getDefaultSlackChannels(client)).filter((c) => c.id != messageChannelId);
for (const channel of channelsToAdd) {
channelSet.add(channel);
}
}

for (const channel of channels) {
channelSet.add(channel);
}

const message = generateNotifyMessage(messageUrl, notifyType);

let singleChannelGuestsMessaged = 0;
try {
if (notifyType == NotifyType.DM_SINGLE_CHANNEL_GUESTS) {
singleChannelGuestsMessaged = await postMessageToSingleChannelGuestsInChannels(
client,
channelSet.values(),
message,
);
} else {
for (const channel of channelSet.values()) {
await postMessage(client, channel, message);
}
}
} catch (e) {
await postEphemeralMessage(
client,
command.channel_id,
command.user_id,
`An error occurred while notifying. Check <#${loggingChannel.id}> for more details.`,
);
return;
}

const responseMessage = `Notified ${
notifyType == NotifyType.DM_SINGLE_CHANNEL_GUESTS ? `${singleChannelGuestsMessaged} single-channel guests in` : ""
} channels ${channelSet
.values()
.map((c) => `\`${c.name}\``)
.join(", ")} about message \`${messageUrl}\``;

await postEphemeralMessage(client, command.channel_id, command.user_id, responseMessage);

SlackLogger.getInstance().info(responseMessage);
}

/**
* Parses the `/notify` command and returns the parameters
* @param command The arguments of the `/notify` command
* @returns The parameters for the notify command
*/
export function parseNotifyCommand(command: string): NotifyParameters {
const tokens = command.split(" ");

if (tokens.length == 1 && tokens[0].trim() == "") {
throw new Error("Please provide a message to send. Usage: `/notify <messageURL>`");
}

// Check if first token is a valid URL
// Links will be wrapped in < and >, so we remove those
const messageUrl = (tokens.shift() as string).replace(/<|>/g, "");
if (!messageUrl.startsWith(`${slackWorkspaceUrl}/archives/`)) {
throw new Error("Please provide a valid message URL from this Slack workspace as the first argument.");
}

let notifyType: NotifyType = NotifyType.DM_SINGLE_CHANNEL_GUESTS;

if (tokens.length > 0) {
if (tokens[0] == "copy") {
notifyType = NotifyType.CHANNEL;
tokens.shift();
} else if (tokens[0] == "copy-ping") {
notifyType = NotifyType.CHANNEL_PING;
tokens.shift();
}
}

const channels: SlackChannel[] = [];
let includeDefaultChannels = false;

if (tokens.length == 0) {
includeDefaultChannels = true;
} else {
for (const channelString of tokens) {
if (channelString == "default") {
includeDefaultChannels = true;
continue;
} else {
let channel: SlackChannel;
try {
channel = parseEscapedSlashCommandChannel(channelString);
} catch (e) {
throw new Error(`Error parsing channel \`${channelString}\`: ${e}`);
}
channels.push(channel);
}
}
}

return { messageUrl, includeDefaultChannels, channels, notifyType };
}

/**
* Generates the notification message for the given parameters
* @param messageUrl The URL of the message to notify about
* @param notifyType The type of notification to generate
* @returns The notification message
*/
export function generateNotifyMessage(messageUrl: string, notifyType: NotifyType): string {
let message = messageUrl;

if (notifyType == NotifyType.CHANNEL_PING) {
message = `<!channel>\n${message}`;
} else if (notifyType == NotifyType.DM_SINGLE_CHANNEL_GUESTS) {
message = `${message}\n_You have been sent this message because you are a single channel guest who might have otherwise missed this alert._`;
}

return message;
}
40 changes: 38 additions & 2 deletions src/utils/channels.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { WebClient } from "@slack/web-api";
import SlackChannel from "../classes/SlackChannel";
import ObjectSet from "../classes/ObjectSet";
import { defaultSlackChannels } from "../common/constants";
import { defaultSlackChannelNames } from "../common/constants";
import { SlackLogger } from "../classes/SlackLogger";
import { slackWorkspaceUrl } from "../common/constants";

/**
* Filters a Slack channel from an array of channels based on its name.
Expand Down Expand Up @@ -61,7 +62,7 @@ export function filterSlackChannelsFromNames(names: string[], channels: SlackCha
* @returns An array of filtered default Slack channels.
*/
export function filterDefaultSlackChannels(channels: SlackChannel[]): SlackChannel[] {
return filterSlackChannelsFromNames(defaultSlackChannels, channels);
return filterSlackChannelsFromNames(defaultSlackChannelNames, channels);
}

/**
Expand Down Expand Up @@ -103,3 +104,38 @@ export async function getDefaultSlackChannels(client: WebClient): Promise<SlackC
const defaultChannels = filterDefaultSlackChannels(allChannels);
return defaultChannels;
}

/**
* Parses the escaped channel text from a Slack command and returns the Slack channel. Escaped channels are in the format <#C12345678|channel-name>
* @param text The escaped channel text
* @returns The Slack channel
*/
export function parseEscapedSlashCommandChannel(text: string): SlackChannel {
// Escaped channel text is in the format <#C12345678|channel-name>
const matches = text.match(/<#(C\w+)\|(.+)>/);
if (matches == null) {
throw new Error(`could not parse escaped channel text: ${text}`);
}

const id = matches[1];
const name = matches[2];
return new SlackChannel(name, id);
}

/**
* Extracts the channel ID from a message link.
* @param messageLink The message link
* @returns The channel ID
*/
export function extractChannelIdFromMessageLink(messageLink: string): string {
if (!messageLink.startsWith(`${slackWorkspaceUrl}/archives/`)) {
throw new Error(`link ${messageLink} is not a valid message link from this Slack workspace`);
}

const matches = messageLink.match(/\/archives\/(\w+)\/p\w+/);
if (matches == null) {
throw new Error(`could not parse message link: ${messageLink}`);
}

return matches[1];
}
4 changes: 4 additions & 0 deletions src/utils/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ export async function postMessageToSingleChannelGuestsInChannels(
text: string,
allUsersInWorkspace?: SlackUser[],
): Promise<number> {
if (allUsersInWorkspace == undefined) {
allUsersInWorkspace = await getAllSlackUsers(client);
}

const allSingleChannelGuestsInChannels = await getAllSingleChannelGuestsInChannels(
client,
channels,
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/slackChannels.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import SlackChannel from "../../src/classes/SlackChannel";
import { defaultSlackChannels as defaultSlackChannelNames } from "../../src/common/constants";
import { defaultSlackChannelNames } from "../../src/common/constants";

export const slackChannels: SlackChannel[] = [
new SlackChannel("admin", "C015DCM3JPN"),
Expand Down
Loading

0 comments on commit 6a95a1f

Please sign in to comment.