diff --git a/src/common/constants.ts b/src/common/constants.ts index acedbb9..82a17a3 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -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", @@ -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"; diff --git a/src/listeners/commands/helpCommand.ts b/src/listeners/commands/helpCommand.ts index ee9854a..43f8958 100644 --- a/src/listeners/commands/helpCommand.ts +++ b/src/listeners/commands/helpCommand.ts @@ -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 .`; -export const helpCommandHandler: Middleware = 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): Promise { await ack(); await logCommandUsed(command); await postEphemeralMessage(client, command.channel_id, command.user_id, message); -}; +} diff --git a/src/listeners/commands/index.ts b/src/listeners/commands/index.ts index e47ea41..86d1912 100644 --- a/src/listeners/commands/index.ts +++ b/src/listeners/commands/index.ts @@ -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 }; diff --git a/src/listeners/commands/notifyCommand.ts b/src/listeners/commands/notifyCommand.ts new file mode 100644 index 0000000..f39260d --- /dev/null +++ b/src/listeners/commands/notifyCommand.ts @@ -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 { + 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((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 `"); + } + + // 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 = `\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; +} diff --git a/src/utils/channels.ts b/src/utils/channels.ts index 2c8cc21..b641bd7 100644 --- a/src/utils/channels.ts +++ b/src/utils/channels.ts @@ -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. @@ -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); } /** @@ -103,3 +104,38 @@ export async function getDefaultSlackChannels(client: WebClient): Promise + * @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]; +} diff --git a/src/utils/users.ts b/src/utils/users.ts index 9cfcc37..967adf5 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -83,6 +83,10 @@ export async function postMessageToSingleChannelGuestsInChannels( text: string, allUsersInWorkspace?: SlackUser[], ): Promise { + if (allUsersInWorkspace == undefined) { + allUsersInWorkspace = await getAllSlackUsers(client); + } + const allSingleChannelGuestsInChannels = await getAllSingleChannelGuestsInChannels( client, channels, diff --git a/tests/fixtures/slackChannels.ts b/tests/fixtures/slackChannels.ts index d359da4..48b4ec7 100644 --- a/tests/fixtures/slackChannels.ts +++ b/tests/fixtures/slackChannels.ts @@ -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"), diff --git a/tests/unit/commands/notifyCommand.test.ts b/tests/unit/commands/notifyCommand.test.ts new file mode 100644 index 0000000..41b8e7a --- /dev/null +++ b/tests/unit/commands/notifyCommand.test.ts @@ -0,0 +1,177 @@ +import { parseNotifyCommand, generateNotifyMessage } from "../../../src/listeners/commands/notifyCommand"; +import { NotifyParameters, NotifyType } from "../../../src/listeners/commands/notifyCommand"; +import SlackChannel from "../../../src/classes/SlackChannel"; + +describe("parseNotifyCommand", () => { + it("parses the /notify command", () => { + const commandArgs = "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000"; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + notifyType: NotifyType.DM_SINGLE_CHANNEL_GUESTS, + includeDefaultChannels: true, + channels: [], + }; + + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("parses the /notify command when link is not plaintext", () => { + const commandArgs = ""; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + includeDefaultChannels: true, + notifyType: NotifyType.DM_SINGLE_CHANNEL_GUESTS, + channels: [], + }; + + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("parses the /notify command with the copy flag", () => { + const commandArgs = "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 copy"; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + notifyType: NotifyType.CHANNEL, + includeDefaultChannels: true, + channels: [], + }; + + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("parses the /notify command with the copy-ping flag", () => { + const commandArgs = "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 copy-ping"; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + notifyType: NotifyType.CHANNEL_PING, + includeDefaultChannels: true, + channels: [], + }; + + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("parses the /notify command with the copy-ping flag and explicit channels", () => { + const commandArgs = + "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 copy-ping <#C12345678|channel-name> <#C12345679|channel-name2>"; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + notifyType: NotifyType.CHANNEL_PING, + includeDefaultChannels: false, + channels: [new SlackChannel("channel-name", "C12345678"), new SlackChannel("channel-name2", "C12345679")], + }; + + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("parses the /notify command with the copy flag and explicit channels", () => { + const commandArgs = + "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 copy <#C12345678|channel-name> <#C12345679|channel-name2>"; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + notifyType: NotifyType.CHANNEL, + includeDefaultChannels: false, + channels: [new SlackChannel("channel-name", "C12345678"), new SlackChannel("channel-name2", "C12345679")], + }; + + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("parses the /notify command with the default channels and specific channels", () => { + const commandArgs = + "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 default <#C12345678|channel-name> <#C12345679|channel-name2>"; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + notifyType: NotifyType.DM_SINGLE_CHANNEL_GUESTS, + includeDefaultChannels: true, + channels: [new SlackChannel("channel-name", "C12345678"), new SlackChannel("channel-name2", "C12345679")], + }; + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("parses the /notify command with the default channels and specific channels (default not first)", () => { + const commandArgs = + "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 <#C12345678|channel-name> default <#C12345679|channel-name2>"; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + notifyType: NotifyType.DM_SINGLE_CHANNEL_GUESTS, + includeDefaultChannels: true, + channels: [new SlackChannel("channel-name", "C12345678"), new SlackChannel("channel-name2", "C12345679")], + }; + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("parses the /notify command with pinging the default channels and specific channels", () => { + const commandArgs = + "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 copy-ping default <#C12345678|channel-name> <#C12345679|channel-name2>"; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + notifyType: NotifyType.CHANNEL_PING, + includeDefaultChannels: true, + channels: [new SlackChannel("channel-name", "C12345678"), new SlackChannel("channel-name2", "C12345679")], + }; + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("parses the /notify command with copy flag to the default channels and specific channels", () => { + const commandArgs = + "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 copy default <#C12345678|channel-name> <#C12345679|channel-name2>"; + const expected: NotifyParameters = { + messageUrl: "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000", + notifyType: NotifyType.CHANNEL, + includeDefaultChannels: true, + channels: [new SlackChannel("channel-name", "C12345678"), new SlackChannel("channel-name2", "C12345679")], + }; + expect(parseNotifyCommand(commandArgs)).toEqual(expected); + }); + + it("fails if both the copy and copy-ping flags are passed", () => { + const commandArgs = "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 copy copy-ping"; + expect(() => parseNotifyCommand(commandArgs)).toThrow(); + }); + + it("fails if both the copy and copy-ping flags are passed with explicit channels specified", () => { + const commandArgs = + "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 copy copy-ping <#C12345678|channel-name> <#C12345679|channel-name2>"; + expect(() => parseNotifyCommand(commandArgs)).toThrow(); + }); + + it("throws an error when the command is empty", () => { + const commandArgs = ""; + expect(() => parseNotifyCommand(commandArgs)).toThrow( + "Please provide a message to send. Usage: `/notify `", + ); + }); + + it("throws an error when the URL is invalid", () => { + const commandArgs = "https://google.com"; + expect(() => parseNotifyCommand(commandArgs)).toThrow( + "Please provide a valid message URL from this Slack workspace as the first argument.", + ); + }); + + it("throws an error when the channels are invalid", () => { + const commandArgs = "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000 #C12345678"; + expect(() => parseNotifyCommand(commandArgs)).toThrow(); + }); +}); + +describe("generateNotifyMessage", () => { + it("generates the notification message for NotifyType.CHANNEL", () => { + const messageUrl = "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000"; + const expected = `${messageUrl}`; + expect(generateNotifyMessage(messageUrl, NotifyType.CHANNEL)).toEqual(expected); + }); + + it("generates the notification message for NotifyType.DM_SINGLE_CHANNEL_GUESTS", () => { + const messageUrl = "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000"; + const expected = `${messageUrl}\n_You have been sent this message because you are a single channel guest who might have otherwise missed this alert._`; + expect(generateNotifyMessage(messageUrl, NotifyType.DM_SINGLE_CHANNEL_GUESTS)).toEqual(expected); + }); + + it("generates the notification message for NotifyType.CHANNEL_PING", () => { + const messageUrl = "https://waterloorocketrydev.slack.com/archives/C015FXXXXXX/p1707843500000000"; + const expected = `\n${messageUrl}`; + expect(generateNotifyMessage(messageUrl, NotifyType.CHANNEL_PING)).toEqual(expected); + }); +}); diff --git a/tests/unit/utils/channels.test.ts b/tests/unit/utils/channels.test.ts new file mode 100644 index 0000000..f5118c2 --- /dev/null +++ b/tests/unit/utils/channels.test.ts @@ -0,0 +1,39 @@ +import { parseEscapedSlashCommandChannel, extractChannelIdFromMessageLink } from "../../../src/utils/channels"; + +describe("parseEscapedSlashCommandChannel", () => { + it("parses the escaped channel text from a Slack command and returns the Slack channel", () => { + const text = "<#C12345678|channel-name>"; + const expected = { + name: "channel-name", + id: "C12345678", + }; + + expect(parseEscapedSlashCommandChannel(text)).toEqual(expected); + }); + + it("Throws an error when the text is not in the correct format", () => { + const text = "channel-name"; + expect(() => parseEscapedSlashCommandChannel(text)).toThrow(`could not parse escaped channel text: ${text}`); + }); +}); + +describe("extractChannelIdFromMessageLink", () => { + it("extracts the channel ID from a message link", () => { + const messageLink = "https://waterloorocketrydev.slack.com/archives/C12345678/p1234567890"; + const expected = "C12345678"; + + expect(extractChannelIdFromMessageLink(messageLink)).toEqual(expected); + }); + + it("Throws an error when the message link is not in the correct format", () => { + const messageLink = "https://waterloorocketrydev.slack.com/archives/C12345678"; + expect(() => extractChannelIdFromMessageLink(messageLink)).toThrow(`could not parse message link: ${messageLink}`); + }); + + it("Throws an error when the message link is not from the Slack workspace", () => { + const messageLink = "https://notwaterloorocketrydev.slack.com/archives/C12345678/p1234567890"; + expect(() => extractChannelIdFromMessageLink(messageLink)).toThrow( + `link ${messageLink} is not a valid message link from this Slack workspace`, + ); + }); +});