Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Respond to mentions with an air quality report #18

Merged
merged 6 commits into from
Oct 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions example.env
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Make a file call .env that has these pieces in it:
SLACK_TOKEN=SAMPLE_SLACK_TOKEN
SLACK_SIGNING_SECRET=SAMPLE_SIGNING_SECRET
PURPLEAIR_API_READ_KEY=SAMPLE_API_READ_KEY
#SILENT=true # This will swallow the slack posts and only output to stdout
#NODE_ENV=test # This will prevent monitoring from happening and run the app quickly
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"license": "MIT",
"private": false,
"dependencies": {
"@slack/events-api": "^2.3.4",
"@slack/web-api": "^5.11.0",
"axios": "^0.20.0",
"dotenv": "^8.2.0",
Expand Down
1 change: 0 additions & 1 deletion src/aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const indoorSensor = {
//TODO: once I have aggregator.js converted to typescript set this type
let aggregator: Aggregator;


beforeEach(() => {
aggregator = new Aggregator([{ name: "Outdoor", sensor: outdoorSensor}, {name: "Indoor", sensor: indoorSensor}]);
indoorSensorData = {};
Expand Down
52 changes: 49 additions & 3 deletions src/aqiDuckController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ jest.mock('./aggregator', () => {

const mockSlackReporterA = {
postMessage: jest.fn(),
getChannelName: jest.fn(),
getChannelName: () => "Reporter A",
getConfig: jest.fn().mockImplementation(() => {
return Promise.resolve("Mock config A");
})
}

const mockSlackReporterB = {
postMessage: jest.fn(),
getChannelName: jest.fn(),
getChannelName: () => "Reporter B",
getConfig: jest.fn().mockImplementation(() => {
return Promise.resolve("Mock config B");
})
Expand All @@ -32,12 +32,58 @@ function flushPromises() {
return new Promise(resolve => setImmediate(resolve));
}

beforeEach(() => {
mockSlackReporterA.postMessage.mockClear()
mockSlackReporterB.postMessage.mockClear()
});

describe(".subscribeAll", () => {
it('should report for each controller', async () => {
expect.assertions(2);
await AqiDuckController.subscribeAll();
await flushPromises;
await flushPromises();
expect(mockSlackReporterA.postMessage).toHaveBeenCalledWith("I am a report for Mock config A")
expect(mockSlackReporterB.postMessage).toHaveBeenCalledWith("I am a report for Mock config B")
});
});

describe("handleEvent", () => {
it("sends a report if the event text says report", async () => {
const controller = new AqiDuckController(mockSlackReporterA);
controller.handleEvent({ text: '<@USERNAMETHING>REPORT' });
expect(mockSlackReporterA.postMessage).toHaveBeenCalledWith("I'm not set up to give you a report!")
mockSlackReporterA.postMessage.mockClear()
await controller.setupAggregator();
controller.handleEvent({ text: '<@USERNAMETHING> report' });
await flushPromises();
expect(mockSlackReporterA.postMessage).toHaveBeenCalledWith("I am a report for Mock config A")
});

it("says hello if the event text says hello", async () => {
const controller = new AqiDuckController(mockSlackReporterA);
await controller.setupAggregator();

mockSlackReporterA.postMessage.mockClear()
controller.handleEvent({ text: '<@USERNAMETHING> Hello' });
await flushPromises();
expect(mockSlackReporterA.postMessage).toHaveBeenCalledWith("Hello there!")

mockSlackReporterA.postMessage.mockClear()
controller.handleEvent({ text: '<@USERNAMETHING> hi there' });
await flushPromises();
expect(mockSlackReporterA.postMessage).toHaveBeenCalledWith("Hello there!")

mockSlackReporterA.postMessage.mockClear()
controller.handleEvent({ text: '<@USERNAMETHING> high' });
await flushPromises();
expect(mockSlackReporterA.postMessage).not.toHaveBeenCalledWith("Hello there!")
});

it("Lets you know if the event text is unknown", async () => {
const controller = new AqiDuckController(mockSlackReporterA);
await controller.setupAggregator();
controller.handleEvent({ text: '<@USERNAMETHING> What' });
await flushPromises();
expect(mockSlackReporterA.postMessage).toHaveBeenCalledWith("I'm not sure how to help with that.")
});
});
85 changes: 60 additions & 25 deletions src/aqiDuckController.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,89 @@
import SlackReporter from './slackReporter';
import Aggregator from './aggregator';

export const ControllerRegistry: Record<string, AqiDuckController> = {};

export default class AqiDuckController {
aggregator: Aggregator;
slackReporter: SlackReporter;
channelId: string;
error: boolean;

//TODO: I can't figure out how to test this if they type is SlackReporter
constructor(slackReporter: any) {
this.slackReporter = slackReporter;
this.channelId = slackReporter.id;
this.error = false;
}

async setupAggregator() : Promise<void> {
const aggregatorConfig = await this.slackReporter.getConfig();
if(!aggregatorConfig) {
console.log('Trying to set up AQIDuck but there is no aggregator config', this.getChannelName());
this.slackReporter.postMessage('Trying to set up AQIDuck but there is no aggregator config');
this.error = true;
return;
}
//TODO: validate config here
this.aggregator = Aggregator.fromConfig(aggregatorConfig);
if(!this.aggregator) {
console.log(`Error setting up reporter from config ${aggregatorConfig}`, this.getChannelName());
this.slackReporter.postMessage(`Error setting up reporter from config ${aggregatorConfig}`);
this.error = true;
return;
}
}

constructor({ slackReporter, aggregator } : { slackReporter: SlackReporter, aggregator: Aggregator }) {
this.slackReporter = slackReporter
this.aggregator = aggregator
getChannelName() : string {
return this.slackReporter.getChannelName();
}

monitorAndNotify() : void {
this.aggregator.monitorAndNotify().then((notification) => {
if(!notification) { return }
this.slackReporter.postMessage(notification);
}).catch((error) => {
console.log("error getting aggregator notification", this.slackReporter.channel, error)
console.log("error getting aggregator notification", this.getChannelName(), error)
});
}

report() : void {
if(!this.aggregator) {
this.slackReporter.postMessage("I'm not set up to give you a report!");
return
}
this.aggregator.report().then((report) => {
this.slackReporter.postMessage(report);
}).catch((error) => {
console.log("error getting aggregator report", this.slackReporter.channel, error)
console.log("error getting aggregator report", this.getChannelName(), error)
});
}

onSetup() : void {
console.log('saying hello to ', this.slackReporter.getChannelName());
//TODO figure out slack event type
handleEvent(event : any) : void {
if(event.text.match(/(\bhello\b|\bhi\b)/i)) {
this.slackReporter.postMessage("Hello there!");
} else if(event.text.match(/report/i)) {
this.report()
} else {
this.slackReporter.postMessage("I'm not sure how to help with that.");
}
}

onStart() : void {
console.log('saying hello to ', this.getChannelName());
this.slackReporter.postMessage("Hello I'm AQIDuck. Let me tell you about the air quality.");
}

onExit() : Promise<void> {
console.log('saying goodbye to ', this.slackReporter.getChannelName());
console.log('saying goodbye to ', this.getChannelName());
return this.slackReporter.postMessage("Ducking out. See you!");
}

start() : void {
this.onSetup();
async start() : Promise<void> {
await this.setupAggregator();
if(this.error) { return }
this.onStart();
if(process.env.NODE_ENV==="test") {
this.report();
} else {
Expand All @@ -48,26 +93,16 @@ export default class AqiDuckController {

}

static async subscribeToAggregatorsForReporter(slackReporter: SlackReporter) : Promise<AqiDuckController> {
const aggregatorConfig = await slackReporter.getConfig();
if(!aggregatorConfig) {
slackReporter.postMessage(`Trying to set up AQIDuck but there is no aggregator config`);
return;
}
//TODO: validate config here
const aggregator = Aggregator.fromConfig(aggregatorConfig);
if(!aggregator) {
slackReporter.postMessage(`Error setting up reporter from config ${aggregatorConfig}`)
return;
}
const controller = new AqiDuckController({ slackReporter, aggregator });
controller.start();
static async startForReporter(slackReporter: SlackReporter) : Promise<AqiDuckController> {
const controller = new AqiDuckController(slackReporter);
ControllerRegistry[slackReporter.id] = controller;
await controller.start();
return controller;
}

static async subscribeAll() : Promise<void> {
const reporters = await SlackReporter.subscribeAll();
const controllerPromises = reporters.map(AqiDuckController.subscribeToAggregatorsForReporter);
const controllerPromises = reporters.map(AqiDuckController.startForReporter);

process.on('SIGINT', async function() {
console.log("Caught interrupt signal");
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import dotenv from 'dotenv';
dotenv.config();

import AqiDuckController from './aqiDuckController.js';
import AqiDuckController from './aqiDuckController';
import attachListeners from './slackListener';


export default async function index() : Promise<void> {
AqiDuckController.subscribeAll();
attachListeners();
}

index();
25 changes: 25 additions & 0 deletions src/slackListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Initialize using signing secret from environment variables
const { createEventAdapter } = require('@slack/events-api'); //eslint-disable-line
const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET);
const port = process.env.PORT || 3000;
import { ControllerRegistry } from './aqiDuckController';

export default function attachListeners() : void {
if(process.env.NODE_ENV === "test") {
return
}
// Attach listeners to events by Slack Event "type". See: https://api.slack.com/events/message.im
slackEvents.on('app_mention', (event : any) => {
console.log(`Received a message event: user ${event.user} in channel ${event.channel} says ${event.text}`);
ControllerRegistry[event.channel].handleEvent(event);
});

// Handle errors (see `errorCodes` export)
slackEvents.on('error', console.error);

// Start a basic HTTP server
slackEvents.start(port).then(() => {
// Listening on path '/slack/events' by default
console.log(`server listening on port ${port}`);
});
}
2 changes: 2 additions & 0 deletions src/slackReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ const web = new WebClient(process.env.SLACK_TOKEN);

class SlackReporter {
channel: basicChannel;
id: string;
topic: {
value: string;
};

constructor(channel : basicChannel) {
this.channel = channel;
this.id = channel.id;
}

async postMessage(text: string) : Promise<void> {
Expand Down