-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
12 changed files
with
840 additions
and
34 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { AllMiddlewareArgs, GenericMessageEvent, Middleware, SlackEventMiddlewareArgs } from "@slack/bolt"; | ||
import { config } from "../triggers/triggers" | ||
import { WHY_MODAL_ACTION } from "./whyDialog"; | ||
|
||
export async function handleMessage(event: SlackEventMiddlewareArgs<'message'> & AllMiddlewareArgs) { | ||
const message = event.message as GenericMessageEvent; | ||
const { text } = message; | ||
|
||
const matches = config.triggers.map((trigger) => ({ | ||
trigger, | ||
match: text.replace(trigger.ignore, "").match(trigger.matches) | ||
})).filter(x => !!x.match).map(({ trigger, match }) => ({ | ||
trigger, | ||
text: match[0] | ||
})); | ||
|
||
if (matches.length === 0) { | ||
// This message contains only ignored items. | ||
return; | ||
} | ||
|
||
event.client.reactions.add({ | ||
name: "eyes", | ||
channel: message.channel, | ||
timestamp: message.ts, | ||
}); | ||
|
||
// Pick a random alternative for each trigger word | ||
const pretexts = matches.map(({ trigger, text }) => { | ||
const random = Math.floor(Math.random() * trigger.alternatives.length); | ||
const alternative = trigger.alternatives[random]; | ||
return `• Instead of saying “${text},” how about *${alternative}*?`; | ||
}); | ||
|
||
event.client.chat.postEphemeral({ | ||
user: message.user, | ||
channel: message.channel, | ||
thread_ts: message.thread_ts, | ||
icon_emoji: ":wave:", | ||
username: "Inclusion Bot", | ||
unfurl_links: false, | ||
unfurl_media: false, | ||
attachments: [ | ||
{ | ||
color: "#ffbe2e", | ||
blocks: pretexts.map((text, i) => { | ||
const block = { | ||
type: "section", | ||
text: { type: "mrkdwn", text }, | ||
} as any; | ||
|
||
if (i === 0) { | ||
block.accessory = { | ||
type: "button", | ||
text: { type: "plain_text", text: "Why?" }, | ||
value: matches.map(({ text: t }) => t).join("|"), | ||
action_id: WHY_MODAL_ACTION, | ||
}; | ||
} | ||
return block; | ||
}), | ||
fallback: "fallback", | ||
}, | ||
{ | ||
color: "#2eb886", | ||
text: config.message, | ||
fallback: config.message, | ||
}, | ||
], | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import { AllMiddlewareArgs, BlockAction, ButtonAction, SlackActionMiddlewareArgs } from "@slack/bolt"; | ||
import { capitalize } from "../helpers/capitalize"; | ||
import { config } from "../triggers/triggers"; | ||
|
||
export const WHY_MODAL_ACTION = "inclusion_modal"; | ||
const defaultExplanation = `":TERM:" doesn’t have an explanation written out yet. Please file an issue if you’re confused!`; | ||
|
||
export async function handleWhyDialog(event: SlackActionMiddlewareArgs<BlockAction<ButtonAction>> & AllMiddlewareArgs) { | ||
await event.ack(); | ||
|
||
const action = event.action; | ||
const matchWords = action.value.split("|"); | ||
|
||
const explanations = matchWords.map((word) => { | ||
const { trigger } = config.triggers.map((trigger) => ({ | ||
trigger, | ||
match: word.match(trigger.matches) | ||
})).filter(x => !!x.match).pop(); | ||
|
||
if (!trigger) { | ||
// Something is amiss — we have a word which triggered the response but | ||
// doesn’t appear in any trigger’s word match list. | ||
return; | ||
} | ||
|
||
const why = trigger.why || defaultExplanation; | ||
return why.replace(/:TERM:/gi, capitalize(word)); | ||
}); | ||
|
||
const blocks = explanations.map((why) => [ | ||
{ | ||
type: "section", | ||
text: { type: "mrkdwn", text: why }, | ||
}, | ||
{ | ||
type: "divider" | ||
}, | ||
]).flat() as any[]; | ||
|
||
// Remove the last divider and replace it with a link to learn more. | ||
blocks.pop(); | ||
blocks.push({ | ||
type: "context", | ||
elements: [ | ||
{ | ||
type: "mrkdwn", | ||
text: `<${config.link}|Read more> about this bot or file an issue.`, | ||
}, | ||
], | ||
}); | ||
|
||
event.client.views.open({ | ||
trigger_id: event.body.trigger_id, | ||
view: { | ||
type: "modal", | ||
title: { | ||
type: "plain_text", | ||
text: "Inclusion Bot" | ||
}, | ||
blocks | ||
} | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import { App, LogLevel } from "@slack/bolt"; | ||
import { handleMessage } from "./actions/message"; | ||
import { handleWhyDialog, WHY_MODAL_ACTION } from "./actions/whyDialog"; | ||
import { config } from "./triggers/triggers"; | ||
|
||
const { | ||
SLACK_TOKEN, | ||
SLACK_SIGNING_SECRET, | ||
PORT = "3000", | ||
} = process.env; | ||
|
||
const app = new App({ | ||
token: SLACK_TOKEN, | ||
signingSecret: SLACK_SIGNING_SECRET, | ||
logLevel: LogLevel.DEBUG, | ||
}); | ||
|
||
(async () => { | ||
await app.start(+PORT); | ||
|
||
const combinedTriggers = new RegExp( | ||
config.triggers.map(trigger => trigger.matches.source).join("|"), | ||
"i" | ||
); | ||
|
||
app.message(combinedTriggers, handleMessage); | ||
app.action(WHY_MODAL_ACTION, handleWhyDialog); | ||
})(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
/** | ||
* Converts a string to Title case. | ||
*/ | ||
export const capitalize = (str: string) => { | ||
return `${str[0].toUpperCase()}${str.slice(1)}`; | ||
} |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import { rawConfig } from "./triggers"; | ||
|
||
describe("Configuration file", () => { | ||
const yml = rawConfig; | ||
|
||
it("starts with a top-level triggers property", () => { | ||
expect(Object.keys(yml).length).toBe(3); | ||
expect(typeof yml.link).toBe("string"); | ||
expect(typeof yml.message).toBe("string"); | ||
expect(Array.isArray(yml.triggers)).toBe(true); | ||
}); | ||
|
||
it("each item is an object, and each property of each object is a string", () => { | ||
const { triggers } = yml; | ||
|
||
triggers.forEach((trigger) => { | ||
expect(typeof trigger).toBe("object"); | ||
|
||
const keys = Object.keys(trigger); | ||
const validKeys = ["matches", "alternatives", "ignore", "why"]; | ||
const invalidKeys = keys.filter((key) => !validKeys.includes(key)); | ||
|
||
expect(keys).toEqual(expect.arrayContaining(["matches", "alternatives"])); | ||
expect(invalidKeys).toHaveLength(0); | ||
|
||
if (keys.includes("ignore")) { | ||
expect(Array.isArray(trigger.ignore)).toBe(true); | ||
} | ||
|
||
if (keys.indexOf("why")) { | ||
expect(typeof trigger.why).toBe("string"); | ||
} | ||
|
||
expect(Array.isArray(trigger.matches)).toBe(true); | ||
expect(trigger.matches.findIndex((v: any) => typeof v !== "string")).toBe(-1); | ||
|
||
expect(Array.isArray(trigger.alternatives)).toBe(true); | ||
expect(trigger.alternatives.findIndex((v: any) => typeof v !== "string")).toBe(-1); | ||
|
||
if (trigger.ignore) { | ||
expect(Array.isArray(trigger.ignore)).toBe(true); | ||
expect(trigger.ignore.findIndex(v => typeof v !== "string")).toBe(-1); | ||
} | ||
|
||
if (trigger.why) { | ||
expect(typeof trigger.why).toBe("string"); | ||
} | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
import { readFileSync } from "fs"; | ||
import { join } from "path"; | ||
import { load } from "js-yaml"; | ||
|
||
/** | ||
* Configuration for the phrases the bot reacts to and what it says about it. | ||
*/ | ||
type TriggerConfig<TriggerType = RawTrigger | Trigger> = { | ||
/** | ||
* Describes the purpose of the bot and encourages people to learn more. This | ||
* is displayed after the list of triggering phrases and suggested alternatives. | ||
*/ | ||
message: string; | ||
|
||
/** | ||
* Where person who triggered the bot can read more about the bot | ||
* and the motivations behind it. | ||
*/ | ||
link: string; | ||
|
||
/** | ||
* The list of phrases that will cause the bot to respond, what it should | ||
* ignore, and alternative words or phrases to suggest. Optionally, there is | ||
* a place to explain more about why the phrase triggered the bot's response. | ||
*/ | ||
triggers: TriggerType[]; | ||
}; | ||
|
||
/** | ||
* Represents a phrase which will cause the bot to respond, what it should | ||
* ignore, and alternative words or phrases to suggest — before it has been | ||
* parsed into regular expressions. Optionally, there is a place to explain | ||
* more about why the phrase triggered the bot's response. | ||
*/ | ||
type RawTrigger = { | ||
/** | ||
* List all of the phrases that should trigger the response. | ||
*/ | ||
matches: string[]; | ||
|
||
/** | ||
* Alternative phrases that should be suggested if this response is triggered. | ||
*/ | ||
alternatives: string[]; | ||
|
||
/** | ||
* Any special cases of the phrases that should be ignored. | ||
*/ | ||
ignore?: string[]; | ||
|
||
/** | ||
* Explains why the triggering phrase is problematic. The placeholder :TERM: | ||
* will be replaced with the specific text that triggered the bot to respond. | ||
*/ | ||
why?: string; | ||
}; | ||
|
||
/** | ||
* Represents a phrase which will cause the bot to respond, what it should | ||
* ignore, and alternative words or phrases to suggest. Optionally, there is a | ||
* place to explain more about why the phrase triggered the bot's response. | ||
*/ | ||
export type Trigger = { | ||
/** | ||
* List all of the phrases that should trigger the response. | ||
*/ | ||
matches: RegExp; | ||
|
||
/** | ||
* Alternative phrases that should be suggested if this response is triggered. | ||
*/ | ||
alternatives: string[]; | ||
|
||
/** | ||
* Any special cases of the phrases that should be ignored. | ||
*/ | ||
ignore?: RegExp; | ||
|
||
/** | ||
* Explains why the triggering phrase is problematic. The placeholder :TERM: | ||
* will be replaced with the specific text that triggered the bot to respond. | ||
*/ | ||
why?: string; | ||
}; | ||
|
||
const path = join(__dirname, "triggers.yml"); | ||
const yamlString = readFileSync(path, "utf-8"); | ||
|
||
/** | ||
* Configuration object before parsing regular expression text. | ||
*/ | ||
export const rawConfig = load(yamlString, { json: true }) as TriggerConfig<RawTrigger>; | ||
|
||
/** | ||
* Configuration object after parsing matchers into regular expressions. | ||
*/ | ||
export const config = { | ||
...rawConfig, | ||
triggers: rawConfig.triggers.map(({ ignore, matches, ...rest }) => ({ | ||
ignore: ignore && RegExp(`\\b(${ignore.join("|")})\\b`, "i"), | ||
matches: RegExp( | ||
// The backend of this regex (starting at "(?=") is using a positive | ||
// lookahead to un-match things that are inside quotes (regular double | ||
// quotes, single quote, or smart quotes). You can play around with the | ||
// regex here: https://regexr.com/61eiq | ||
`\\b(${matches.join("|")})(?=[^"“”']*(["“”'][^"“”']*["“”'][^"“”']*)*$)\\b`, | ||
"i" | ||
), | ||
...rest | ||
})) | ||
} as TriggerConfig<Trigger>; |
Oops, something went wrong.