Skip to content

Commit

Permalink
Implement bot.
Browse files Browse the repository at this point in the history
  • Loading branch information
adunkman committed Aug 29, 2021
1 parent 5a1a908 commit 851864f
Show file tree
Hide file tree
Showing 12 changed files with 840 additions and 34 deletions.
71 changes: 71 additions & 0 deletions bot/actions/message.ts
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,
},
],
});
}
63 changes: 63 additions & 0 deletions bot/actions/whyDialog.ts
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
}
});
};
28 changes: 28 additions & 0 deletions bot/app.ts
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);
})();
6 changes: 6 additions & 0 deletions bot/helpers/capitalize.ts
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)}`;
}
17 changes: 0 additions & 17 deletions bot/main.ts

This file was deleted.

50 changes: 50 additions & 0 deletions bot/triggers/triggers.test.ts
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");
}
});
});
});
111 changes: 111 additions & 0 deletions bot/triggers/triggers.ts
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>;
Loading

0 comments on commit 851864f

Please sign in to comment.