Skip to content

Instantly share code, notes, and snippets.

@bradennapier
Last active March 28, 2019 17:55
Show Gist options
  • Save bradennapier/893d2772e8501171198022cd401f5664 to your computer and use it in GitHub Desktop.
Save bradennapier/893d2772e8501171198022cd401f5664 to your computer and use it in GitHub Desktop.
Example of a Slack API flow using AWS, SQS, and a nice automated handling of commands
/* @flow */
import type { Bot$Interface } from 'types/bot';
import slack from 'lib/slack';
import * as utils from './utils';
function handleDeferredExecution(result) {
if (bot.state.isWorker) {
// we do not want to allow defer from defer as it will loop
console.error('\n\nCRITICAL ERROR: bot.defer was called from inside worker function!\n\n');
return;
}
// Defer request to the worker thread,
// respond as quickly as possible to
// the caller. We don't send to worker
// queue until next tick to avoid situations
// where the queue responds before the
// return.
process.nextTick(() => {
bot.utils.addToWorkerQueue(bot.request);
});
if (result === bot.defer) {
return;
}
return {
text: `Please hodl while I analyze the results :parrot_smart: ${
bot.request.command ? `of: \`${bot.request.command} ${bot.request.text}\`` : ''
}`,
};
}
const bot: Bot$Interface = {
/* Used to defer execution to the worker thread and return
the appropriate response to slack to inform the api. */
defer: Symbol.for('@bot/defer'),
// we need to FlowIgnore these as they will always
// be defined during run but don't want to require
// existence checks on every use of the properties.
// $FlowIgnore
state: {},
// $FlowIgnore
request: {},
/*
Our parsed workflows are presented here once
we run bot.build(workflows). They are given
as two maps that are flat
Map{
'path.to.value' => Command | Effect | Workflow
}
*/
workflows: {},
utils,
setup(request, config) {
Object.assign(bot, {
request,
state: {
config,
isWorker: Boolean((request && request.isWorker) || false),
path: [],
args: [],
aclUser: undefined,
},
});
},
/**
* This is a top level function that resets the bot for handling
* a new request.
*
* @param {*} state
*/
async run(request, config) {
bot.setup(request, config);
const result = await utils.parseRequest(bot);
if (!result) {
return 'Unknown Result?';
}
if (result === bot.defer) {
return handleDeferredExecution(result);
}
const isHelpRequest = bot.state.args[bot.state.args.length - 1] === 'help';
if (typeof result === 'object') {
if (Array.isArray(result.errors) && result.errors.length > 0) {
return result;
}
if (
(isHelpRequest && result.meta)
|| (typeof result.execute !== 'function'
&& result.execute !== bot.defer
&& result.meta
&& result.meta.hint)
) {
return {
attachments: [
{
color: isHelpRequest ? '#25e019' : '#ff1c1c',
fields: [
!isHelpRequest
? {
title: 'Invalid Command',
value: bot.request.command
? `${bot.request.command} ${bot.request.text}`
: 'Unknown Command',
}
: undefined,
result.meta.title
? {
title: 'Command',
value: result.meta.title,
}
: undefined,
result.meta.hint
? {
title: 'Description',
value: result.meta.hint,
}
: undefined,
result.meta.example
? {
title: 'Example',
value: result.meta.example,
}
: undefined,
Array.isArray(result.subcommands)
? {
title: 'Authorized Scopes',
value: bot.state.aclUser
? result.subcommands
.map(cmd => bot.state.aclUser.isAdmin || bot.state.aclUser.scopes.includes(cmd)
? `\`${cmd}\``
: undefined)
.filter(Boolean)
.join(', ') || 'None'
: 'None',
}
: undefined,
],
footer: 'King IDEX',
},
],
};
}
if (typeof result.execute === 'function') {
// Execute the function
return result.execute();
}
if (result.execute === bot.defer) {
return handleDeferredExecution(result);
}
}
return result;
},
/* Should be called at the startup of the process with the
bot workflows that will be parsed and used for this bot. */
build(workflows) {
bot.workflows = bot.utils.parseWorkflows(bot, workflows);
},
/**
* Receives a Bot$Dialog$Creator (a dialog or a function taking
* arguments and returning a dialog), creates the dialog if
* needed by calling the creator with the given arguments, then
* calls the Slack API with the produced dialog.
*
* @param {Bot$Dialog | (...args[]) => Bot$Dialog} dialogCreator
* @param {Array<any>} args
*/
async dialog(dialogCreator, ...args) {
const dialog = typeof dialogCreator === 'function' ? dialogCreator(...args) : dialogCreator;
if (typeof dialog !== 'object') {
throw new Error('[ERROR] | [BOT] | Invalid Dialog Received');
}
await slack.openDialog(bot.request, dialog);
},
/**
* Executes a given `messageID` and passes the messageCreator
* any provided arguments.
*
* @param {*} workflow
* @param {*} messageID
* @param {...any} args
*/
async send(messageCreator, ...args) {
const message = typeof messageCreator === 'function' ? messageCreator(...args) : messageCreator;
if (typeof message !== 'object') {
throw new Error('[ERROR] | [BOT] | Invalid Message Received during a bot.send() call');
}
const promises = [];
if (Array.isArray(message.channel)) {
message.channel.forEach(channel => {
if (message.ts && message.channel) {
if (message.delete) {
promises.push(slack.deleteMessage({ ...message, channel }));
} else {
promises.push(slack.updateMessage({ ...message, channel }));
}
} else {
promises.push(slack.sendMessage({ ...message, channel }));
}
});
} else if (message.ts && message.channel) {
if (message.delete) {
promises.push(slack.deleteMessage(message));
} else {
promises.push(slack.updateMessage(message));
}
} else {
promises.push(slack.sendMessage(message));
}
return Promise.all(promises);
},
async reply(replyCreator, ...args) {
const message = typeof replyCreator === 'function' ? replyCreator(...args) : replyCreator;
// TODO: Instead of logging this should post to a given channel for general errors in slack.
if (!bot.request.response_url) {
console.error(
'ERROR: Response URL not found in request but tried to send a Reply! ',
bot.request,
message,
);
}
if (typeof message !== 'object') {
throw new Error('[ERROR] | [BOT] | Invalid Message Received during a bot.send() call');
}
await slack.reply(bot.request, message);
},
};
export default bot;
/* @flow */
import type { Bot$Interface, Bot$Workflows, Bot$Workflow$Segment } from 'types/bot';
type Bot$ParsedSegment = {
id: string,
meta: $PropertyType<Bot$Workflow$Segment, 'meta'>,
path: string[],
subcommands?: string[],
components?: any,
children?: any,
};
type Bot$ParsedEffect = {
id: string,
path: string[],
execute: Function,
};
/*
The goal while parsing the workflows format is to
switch it into a simple and flat format based on
the environment so that we can easily capture
the required functionality based on the way we
receive the data from the Slack API.
We essentially boil the workflows down to both a
parsed standard tree:
{
id: '',
path: ['', ''],
meta: {},
components: {},
children: {}
}
as well as a set of maps which are the path joined
by a "." as the key
"idex.blacklist.add" => { ...DescriptorType }
*/
function parseSegmentComponents(
bot,
segment,
parsed,
commandMap,
effectMap,
eventMap,
validatorMap,
) {
const components = {};
if (segment.components) {
const { components: segmentComponents } = segment;
Object.keys(segmentComponents).forEach(componentID => {
let result;
switch (componentID) {
case 'dialogs':
case 'messages': {
break;
}
case 'events': {
const component = segmentComponents[componentID];
if (!component) return;
result = parseEvents(component, segment, parsed, eventMap);
break;
}
case 'effects': {
const component = segmentComponents[componentID];
if (!component) return;
result = parseEffects(component, segment, parsed, effectMap);
break;
}
case 'commands': {
const component = segmentComponents[componentID];
if (!component) return;
result = parseCommands(component, segment, parsed, commandMap);
break;
}
case 'validators': {
const component = segmentComponents[componentID];
if (!component) return;
result = parseValidators(component, segment, parsed, validatorMap);
break;
}
default: {
throw new Error(
`An Unknown Component was discovered while parsing bot workflows: ${componentID} at ${parsed.path.join(
'.',
)}`,
);
}
}
if (result) {
components[componentID] = result;
}
});
}
return components;
}
function parseCommands(component, segment, parsed, map) {
const commandsMap = new Map();
Object.keys(component).forEach(commandID => {
const commandPath = [...parsed.path, commandID];
const command = component[commandID];
const parsedCommand = {
...command,
id: commandID,
path: commandPath,
};
commandsMap.set(commandID, parsedCommand);
map.set(commandPath.join('.'), parsedCommand);
});
return commandsMap;
}
function parseEffects(component, segment, parsed, map) {
const effectsMap = new Map();
Object.keys(component).forEach(effectID => {
const effectPath = [...parsed.path, effectID];
const effect = component[effectID];
const parsedEffect = {
id: effectID,
path: effectPath,
execute: effect,
};
effectsMap.set(effectID, parsedEffect);
map.set(effectPath.join('.'), parsedEffect);
});
return effectsMap;
}
function parseEvents(component, segment, parsed, map) {
const eventsMap = new Map();
Object.keys(component).forEach(eventID => {
const eventPath = [...parsed.path, eventID];
const event = component[eventID];
const parsedEvent = {
id: eventID,
path: eventPath,
execute: event,
};
eventsMap.set(eventID, parsedEvent);
map.set(eventPath.join('.'), parsedEvent);
});
return eventsMap;
}
function parseValidators(component, segment, parsed, map) {
const eventsMap = new Map();
Object.keys(component).forEach(eventID => {
const eventPath = [...parsed.path, eventID];
const validator = component[eventID];
const parsedEvent = {
id: eventID,
path: eventPath,
execute: validator,
};
eventsMap.set(eventID, parsedEvent);
map.set(eventPath.join('.'), parsedEvent);
});
return eventsMap;
}
function parseChildren(
bot: Bot$Interface,
children: Bot$Workflows,
path: Array<string>,
commandMap: Map<string, Bot$ParsedSegment>,
effectMap: Map<string, Bot$ParsedEffect>,
eventMap: Map<string, any>,
validatorMap: Map<string, Bot$ParsedEffect>,
) {
return Object.keys(children).reduce((parsed, childID) => {
const segment = children[childID];
if (typeof segment !== 'object') {
return parsed;
}
if (!segment.meta) {
throw new Error(
`[ERROR] | [BOT] | Failed to parse workflow segment, meta property not found at ${path.join(
' ',
)} > ${childID}`,
);
}
const segmentID = segment.meta.path || childID;
const segmentPath = [...path, segmentID];
const parsedSegment: Bot$ParsedSegment = {
id: segmentID,
path: segmentPath,
meta: segment.meta,
};
commandMap.set(segmentPath.join('.'), parsedSegment);
if (segment.children) {
parsedSegment.children = parseChildren(
bot,
segment.children,
segmentPath,
commandMap,
effectMap,
eventMap,
validatorMap,
);
}
parsedSegment.components = parseSegmentComponents(
bot,
segment,
parsedSegment,
commandMap,
effectMap,
eventMap,
validatorMap,
);
if (parsedSegment.children || (parsedSegment.components && parsedSegment.components.commands)) {
const subcommands = [];
if (parsedSegment.children) {
subcommands.push(...Object.keys(parsedSegment.children));
}
if (parsedSegment.components && parsedSegment.components.commands) {
subcommands.push(...[...parsedSegment.components.commands.keys()]);
}
if (subcommands.length > 0) {
parsedSegment.subcommands = subcommands;
}
}
parsed[segmentID] = parsedSegment;
return parsed;
}, {});
}
/**
* Receives the SlackBot Workflows and parses through it, building
* any necessary context to help us while processing execution of
* the given workflow.
*/
export function parseWorkflows(bot: Bot$Interface, workflows: Bot$Workflows) {
const rootPath = [];
const rootCommandHashMap: Map<string, Bot$ParsedSegment> = new Map();
const rootEffectHashMap: Map<string, Bot$ParsedEffect> = new Map();
const rootEventHashMap: Map<string, any> = new Map();
const rootValidatorsHashMap: Map<string, Bot$ParsedEffect> = new Map();
const children = parseChildren(
bot,
workflows,
rootPath,
rootCommandHashMap,
rootEffectHashMap,
rootEventHashMap,
rootValidatorsHashMap,
);
return {
commands: rootCommandHashMap,
effects: rootEffectHashMap,
events: rootEventHashMap,
validators: rootValidatorsHashMap,
children,
};
}
/* @flow */
import type { Slack$Payloads } from 'types/slack';
import run from 'aws-lambda-runner';
import SecretsPlugin from 'plugins/secrets';
import SlackValidatorPlugin from 'plugins/slack-validator';
import bot from 'lib/bot';
import * as workflows from 'idex/bot/workflows';
bot.build(workflows);
const isProd = process.env.OUR_ENV === 'prod';
const isDev = !isProd;
export default run(
{
settings: {
log: true
},
response: {
headers: {
'Content-Type': 'application/json',
},
},
plugins: new Set([
// asynchronous plugin resolution
[
// synchronous plugin resolution
SecretsPlugin({
secrets: slackSecretsID,
}),
new Set([
// asynchronous plugin resolution
SlackValidatorPlugin({
slackSecretsID,
}),
]),
],
]),
},
async (body, config) => {
const payload: Slack$Payloads = config.state.slackPayload;
// if slash command is /dev we switch to /idex
if (payload.command === '/<dev_command>') {
// $FlowIgnore
payload.command = '/<your_command>';
}
if (isDev) {
console.log('Slack Payload: ', JSON.stringify(payload, null, 2));
}
try {
if (config.request.isProxy) {
switch (config.request.method) {
case 'POST': {
const result = await bot.run(payload, config);
return result;
}
default: {
break;
}
}
}
} catch (err) {
await Promise.all([
payload.response_url
&& bot
.reply({
text: `*ERROR During Request:* ${err.message}`,
})
.catch(() => {}),
bot
.send({
channel: 'idex-bot',
text: `*ERROR During Request: *${err.message}\n\n${JSON.stringify(payload)}\n${
err.stack
}`,
})
.catch(() => {}),
]);
}
},
);
/* @flow */
import type { Slack$Payloads } from 'types/slack';
import run from 'aws-lambda-runner';
import resolveSequentially from 'utils/resolve-sequentially';
import SecretsPlugin from 'plugins/secrets';
import bot from 'lib/bot';
import * as workflows from 'idex/bot/workflows';
bot.build(workflows);
export default run(
{
settings: {
log: true,
},
plugins: new Set([
// TODO : Is this needed for worker?
SecretsPlugin({
secrets: slackSecretsID,
}),
]),
},
async (body = {}, config) => {
// we normalize the slackSecrets value since we need to dynamically adjust this
config.state.slackSecrets = config.state.secrets[slackSecretsID];
if (Array.isArray(body.Records)) {
const requests = body.Records.map(job => {
const request: Slack$Payloads = JSON.parse(job.body);
// we need to indicate that we are in the worker handler
// so that the bot knows that it is ok to run the asynchronous
// effects when it hits a `bot.defer` response.
request.isWorker = true;
if (payload.command === '/<dev_command>') {
// $FlowIgnore
payload.command = '/<your_command>';
}
return async () => {
try {
await bot.run(request, config);
} catch (err) {
await Promise.all([
request.response_url
&& bot
.reply({
text: `*ERROR During Request:* ${err.message}`,
})
.catch(() => {}),
bot
.send({
channel: '<debug_channel>',
text: `*ERROR During Request: *${err.message}\n\n${JSON.stringify(request)}\n${
err.stack
}`,
})
.catch(() => {}),
]);
}
};
});
if (requests.length) {
resolveSequentially(requests);
}
}
},
);
/* @flow */
import SQSClient from 'serverless-sqs-client';
const { APP_AWS_REGION, WORKER_QUEUE_URL } = process.env;
export const SQS = SQSClient({
region: APP_AWS_REGION,
});
export function addToWorkerQueue(body: Object): Promise<any> {
return SQS.sendMessage({
MessageBody: JSON.stringify(body),
QueueUrl: WORKER_QUEUE_URL,
})
.promise()
.then(r => {
console.log('Successfully Published to Worker Queue!', r);
return r;
})
.catch(err => {
console.error('[ERROR] | Failed to Publish to Worker Queue! ', err);
});
}
/* @flow */
import type { Runner$PluginInterface } from 'aws-lambda-runner';
import type { AWS$Secrets$Slack } from 'types/secrets';
import type { Slack$Payloads } from 'types/slack';
import crypto from 'crypto';
type Slack$Payload$String = {|
+payload: string,
|};
/*
Slack Validator Plugin
? IMPORTANT: Must receive secrets (be synchronous after retrieval)
Parses the received payload and confirms that the request is
valid and should be accepted. If it determines that a request
is either invalid or malicious it will throw an error.
*/
type Plugin$Settings = {|
stateID: 'slackPayload' | string,
secretsStateID: 'secrets' | string,
slackSecretsID: string,
validateToken: boolean | true,
validateSignature: boolean | true,
validatePayload: boolean | true,
|};
type Plugin$RequiredSettings = $Shape<Plugin$Settings>;
/**
* @author Braden Napier
* @date 2018-08-21
* @param {Slack$Payloads} payload
* @param {AWS$Secrets.slack} secrets
*/
function validatePayload(payload, secrets) {
if (typeof payload !== 'object') {
throw new TypeError('Invalid Payload');
}
if (payload.team_id && secrets.team_id && payload.team_id !== secrets.team_id) {
throw new TypeError('Invalid TeamID Received');
}
}
/**
*
* @see https://api.slack.com/docs/verifying-requests-from-slack
* @see https://github.com/slackapi/node-slack-interactive-messages/blob/master/src/http-handler.js#L62
* @author Braden Napier
* @date 2018-08-21
* @param {Runner$RuntimeConfig} config
* @param {AWS$Secrets.slack} settings
*/
function validateSignature(config, secrets) {
const receivedSignature = config.request.headers['x-slack-signature'];
const timestamp = Number(config.request.headers['x-slack-request-timestamp']);
const rawBody = String(config.request.body);
if (!receivedSignature || Number.isNaN(timestamp) || !rawBody) {
throw new Error('Failed to Validate Slack Signature (headers)');
}
// Divide current date to match Slack ts format
// Subtract 5 minutes from current time
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5;
if (timestamp < fiveMinutesAgo) {
throw new Error('Failed to Validate Slack Signature (timestamp)');
}
const hmac = crypto.createHmac('sha256', secrets.signing_secret.toString());
const [version, hash] = receivedSignature.split('=');
hmac.update(`${version}:${timestamp}:${rawBody}`);
if (hash !== hmac.digest('hex')) {
throw new Error('Failed to Validate Slack Signature');
}
}
/**
* ! Since this is deprecated by slack, when enabled we do very
* ! quick and dirty checks just to be safe, it should never be
* ! depended upon, signature validation must be used.
*
* https://api.slack.com/docs/verifying-requests-from-slack#verification_token_deprecation
*
* @author Braden Napier
* @date 2018-08-21
* @param {Slack$Payloads} payload
* @param {AWS$Secrets.slack} secrets
* @param {boolean} signatureValidated Was the signature already validated?
* @deprecated
*/
function validateToken(payload, secrets, signatureValidated) {
if (!payload.token && !signatureValidated) {
// when signature validation is disabled, the token must be
// found or we can not securely accept the payload
throw new Error('Invalid Slack Verification Token');
} else if (payload.token && payload.token !== secrets.verification_token) {
throw new Error('Failed to Validate Slack Verification Token');
}
}
const getPluginSettings = (settings?: Plugin$RequiredSettings = {}): Plugin$Settings => ({
/* Since we may further parse the slack payload, we save the parsed payload to the config.state[settings.stateID] */
stateID: settings.stateID || 'slackPayload',
/* If a custom stateID is utilized to capture the secrets, provide it here. */
secretsStateID: settings.secretsStateID || 'secrets',
slackSecretsID: settings.slackSecretsID || 'slack_development',
/* Validate signature scheme? */
validateSignature: settings.validateSignature !== false,
/* Validate verificationToken in payload? */
// * This is deprecated but we still parse it by default
validateToken: settings.validateToken !== false,
/* Validate payload when applicable? */
// * Validates various values that are potential such as team_id when
// * we have it in our secrets
validatePayload: settings.validatePayload !== false,
});
const RunnerPluginSlackValidatorFactory = (
requiredSettings?: Plugin$RequiredSettings,
): Runner$PluginInterface => {
const settings = getPluginSettings(requiredSettings);
return Object.freeze({
onExecute(_data: Slack$Payloads | Slack$Payload$String, config) {
const data = _data || config.request.queries;
const { [settings.slackSecretsID]: secrets }: AWS$Secrets$Slack = config.state[
settings.secretsStateID
];
if (typeof secrets !== 'object') {
throw new Error('Failed to retrieve secrets data, slack request could not be validated.');
}
let payload: Slack$Payloads;
if (typeof data.payload === 'string') {
payload = (JSON.parse(data.payload): Slack$Payloads);
} else {
// Flow is stupid af
// $FlowIgnore
payload = data;
}
if (typeof payload !== 'object') {
throw new Error(`Expected payload to be an object but received ${typeof payload}`);
}
if (settings.validateSignature) {
validateSignature(config, secrets);
}
if (settings.validatePayload) {
validatePayload(payload, secrets);
}
if (settings.validateToken) {
validateToken(payload, secrets, settings.validateSignature);
}
// save to our stateID in config.state
config.state[settings.stateID] = payload;
config.state.slackSecrets = secrets;
},
});
};
export default RunnerPluginSlackValidatorFactory;
/* @flow */
import type { AWS$Secrets$Slack } from 'types/secrets';
import type { Slack$Payloads } from 'types/slack';
import type { Bot$Dialog, Bot$Message } from 'types/bot/components';
import bot from 'lib/bot';
import { request, rawRequest, getRequest } from './request';
export default Object.freeze({
reply(body: Slack$Payloads, response: Bot$Message) {
if (!body.response_url) {
throw new Error(
'Slack Reply requires that "response_url" is included in the body of the original message',
);
}
return rawRequest(body.response_url, response);
},
openDialog(body: Slack$Payloads, dialog: Bot$Dialog) {
if (!body.token || !body.trigger_id) {
throw new Error('Slack Dialog requires a token and trigger_id but one was missing.');
}
return request(
'dialog.open',
{
token: body.token,
trigger_id: body.trigger_id,
dialog: JSON.stringify(dialog),
},
body,
);
},
sendMessage(message: Bot$Message) {
return request('chat.postMessage', message);
},
updateMessage(message: Bot$Message) {
return request('chat.update', message);
},
deleteMessage(message: Bot$Message) {
return request('chat.delete', message);
},
addReaction(reaction: Object) {
return request('reactions.add', reaction);
},
getUserByID(uid: string) {
const { slackSecrets }: { slackSecrets: AWS$Secrets$Slack } = bot.state.config.state;
return getRequest('users.info', {
token: slackSecrets.oauth_token,
user: uid,
});
},
});
/* @flow */
import type { Slack$Payloads } from 'types/slack';
import type { AWS$Secrets$Slack } from 'types/secrets';
import axios from 'axios';
import bot from '../bot';
let requester;
function createRequester() {
const { slackSecrets }: { slackSecrets: AWS$Secrets$Slack } = bot.state.config.state;
// we need to do this so we can have access to the
// secrets on the first request
return axios.create({
baseURL: 'https://slack.com/api',
headers: {
'User-Agent': 'idex-slackbot',
'Content-Type': 'application/json',
Authorization: `Bearer ${slackSecrets.oauth_token}`,
},
});
}
/**
* OK Check for Responses
*
* @param {object} response - The API response to check
* @return {Promise} A promise with the API response
*/
async function getData(response) {
const { status, data, statusText } = response;
console.log('Get Data: ', statusText, data);
if ((status >= 200 && status < 400) || statusText.toLowerCase() === 'ok') {
return data;
}
throw new Error(JSON.stringify(data));
}
function handleError(err, body?: Slack$Payloads) {
console.error('----------------------------');
console.error('An Error Occurred While Calling the Slack API');
console.log('Request: ', body);
console.log('Error: ', err);
if (err.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.error(err.response.data);
console.error(err.response.status);
console.error(err.response.headers);
} else if (err.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
console.error(err.request);
} else {
// Something happened in setting up the request that triggered an Error
}
console.error('----------------------------');
if (body && body.response_url) {
let msg;
if (err.message) {
console.log('error.message');
const result = JSON.parse(err.message);
[msg] = result.response_metadata.messages;
} else if (err.error) {
console.log('error.error');
msg = err.error;
} else {
msg = 'Unknown Error Occurred During Slack API Request';
}
return rawRequest(body.response_url, {
text: msg,
});
}
throw new Error(err.message);
}
export function rawRequest(url: string, data: Object) {
return axios
.post(url, data)
.then(getData)
.catch(handleError);
}
export function request(method: string, data: Object, body?: Slack$Payloads) {
if (!requester) {
requester = createRequester();
}
return requester
.post(method, data)
.then(getData)
.catch(e => handleError(e, body));
}
export function getRequest(method: string, params: Object) {
const { slackSecrets }: { slackSecrets: AWS$Secrets$Slack } = bot.state.config.state;
return axios({
method: 'get',
url: method,
baseURL: 'https://slack.com/api',
params: {
...params,
token: slackSecrets.oauth_token,
},
headers: {
'User-Agent': 'idex-slackbot',
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.then(getData)
.catch(handleError);
}
export function customRequest(method: string, settings: Object) {
const { slackSecrets }: { slackSecrets: AWS$Secrets$Slack } = bot.state.config.state;
return axios({
url: method,
baseURL: 'https://slack.com/api',
...settings,
headers: {
'User-Agent': 'idex-slackbot',
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${slackSecrets.oauth_token}`,
...settings.headers,
},
})
.then(getData)
.catch(handleError);
}
// Super quick copy paste of types in here since they are separated by modules in actual project
/* @flow */
export type {
Bot$Workflow$Children as Bot$Workflows,
Bot$Component$Dialogs,
Bot$Component$Commands,
Bot$Workflow$Segment,
} from './workflows';
export type { Bot$Dialog } from './components/dialog';
export type { Bot$Message } from './components/message';
export type Bot$Interface = {|
defer: Symbol,
request: Slack$Payloads,
utils: typeof utils,
state: {
config: Runner$RuntimeConfig,
isWorker: boolean,
[key: string]: any,
},
workflows: any,
setup(request?: Slack$Payloads, config: Runner$RuntimeConfig): void,
run(request: Slack$Payloads, config: Runner$RuntimeConfig): Promise<Slack$RequestResponse>,
build(workflows: Bot$Workflows): void,
dialog(dialogCreator: Bot$Dialog$Creator, ...args: any[]): Promise<void>,
send(messageCreator: Bot$Message$Creator, ...args: any[]): Promise<void>,
reply(replyCreator: Bot$Message$Creator, ...args: any[]): Promise<void>,
|};
/* Workflows are deeply nested objects that follow a common
schema along the way, making sure that we can automatically
handle help requests and provide the right information to
the channel at the right time. */
export type Bot$Workflow$Children = {
[childID: string]: Bot$Workflow$Segment,
};
export type Bot$Component$Dialogs = {
[dialogID: string]: Bot$Dialog$Creator,
};
export type Bot$Component$Commands = {
[commandID: string]: Bot$Command,
};
export type Bot$Component$Validators = {
[validatorID: string]: Bot$Validator$Execute,
};
export type Bot$Workflow$Components = {|
/* dialogs provide interactive forms that the
user can fill out
@see https://api.slack.com/dialogs */
dialogs?: Bot$Component$Dialogs,
/* messages are selectors that are used to build messages
that will be sent to slack
*/
messages?: any,
/*
When we receive an event callback with a `callback_id` property it will get
executed within the events object. These events are generally asynchronous
and will perform some action(s) and reply using the `response_url` property
and a pre-defined message or dialog (from the properties above).
For example, when a dialog is executed and the user submits their results
a path to the appropriate event would be given. This event receive the
users inputs from the dialog, performs any required actions, and optionally
may post updates to the user along the way.
*/
events?: any,
commands: Bot$Component$Commands,
validators?: Bot$Component$Validators,
/*
Asynchronous Effects are executed in their own "worker thread" (lambda call)
when we need to reply to a user in a time that may taken more than 3 seconds
(slack maximum for synchronous replies).
These are automatically executed if the matching value in "commands" returns
a `bot.defer`.
*/
effects?: {
[effectID: string]: () => void | Promise<void>,
},
|};
export type Bot$Workflow$Segment = {|
meta: Bot$Workflow$Meta,
/* Nest the command further with new children */
children?: Bot$Workflow$Children,
/* Provide commands, interactions, and options at this level */
components?: Bot$Workflow$Components,
subcommands?: Array<string>,
|};
export type Bot$Workflow$Meta = {|
/* What should the label (title/header) be for this command? */
title: string,
/* What is the path / command at this level? If not provided we use the key
in the object that was used to reach the command */
path?: string,
/* When the user requests help at this level, this will
be displayed. This should be provided at every
level. We will automatically provide the user with
the available commands (children).
Note: Slack Markdown is accepted here */
hint: string,
/* Optionally provide an example command to include with the output */
example?: string,
|};
type Bot$Defer = Symbol;
export type Bot$Command$Execute = (
...args: Array<string>
) => Promise<Bot$Defer | void | Bot$Message> | Bot$Defer | void | Bot$Message;
export type Bot$Command = {
meta: Bot$Workflow$Meta,
execute: Bot$Defer | Bot$Command$Execute,
};
/* https://api.slack.com/dialogs */
export type Bot$Dialog$Creator = ((...args: any[]) => Bot$Dialog) | Bot$Dialog;
export type Bot$Dialog = {
/* User-facing title of this entire dialog.
Can be up to 24 characters */
title: string,
/* An identifier strictly for you to recognize submissions
of this particular instance of a dialog. Use something
meaningful to your app. 255 characters maximum. */
callback_id?: string,
/* Default is false. When set to true, we'll notify your
request URL whenever there's a user-induced dialog
cancellation. */
notify_on_cancel?: boolean | false,
/* An optional string that will be echoed back to your app
when a user interacts with your dialog. Use it as a
pointer to reference sensitive data stored elsewhere. */
state?: string,
/* User-facing string for whichever button-like thing
submits the form, depending on form factor. */
submit_label?: string | 'Submit',
/* Up to 5 form elements are allowed per dialog. */
elements: Array<void | Slack$Dialog$Elements>,
};
/* https://api.slack.com/dialogs#elements */
export type Slack$Dialog$Elements = Slack$Dialog$Element$Text | Slack$Dialog$Element$Select;
export type Slack$Dialog$Element$Common = {|
/* Label displayed to user. Required.
24 character maximum. */
label: string,
/* Name of form element. Required. No more
than 300 characters. */
name: string,
/* Provide true when the form element is not required.
By default, form elements are required. */
optional?: 'true',
/* Helpful text provided to assist users in answering
a question. Up to 150 characters. */
hint?: string,
/* A default value for this field. Up to 150 characters. */
value?: string,
/* A string displayed as needed to help guide users in
completing the element. 150 character maximum. */
placeholder?: string,
|};
export type Slack$Dialog$Element$Text = {|
...Slack$Dialog$Element$Common,
+type: 'text' | 'textarea',
subtype?: 'email' | 'number' | 'tel' | 'url',
/* 0-150 (defaults 0) (0-3000 for textarea) */
min_length?: number,
/* 1-150 (1-3000 for textarea) */
max_length?: number,
|};
export type Slack$Dialog$Element$Select =
| Slack$Dialog$Element$Select$Options
| Slack$Dialog$Element$Select$OptionsGroups
| Slack$Dialog$Element$Select$DataSource$External
| Slack$Dialog$Element$Select$DataSource;
export type Slack$Dialog$Element$Select$Option = {|
label: string,
value: string,
|};
export type Slack$Dialog$Element$Select$OptionGroup = {|
label: string,
options: Array<Slack$Dialog$Element$Select$Option>,
|};
export type Slack$Dialog$Element$Select$Common = {|
...Slack$Dialog$Element$Common,
+type: 'select',
|};
export type Slack$Dialog$Element$Select$DataSource = {
...Slack$Dialog$Element$Select$Common,
/* In addition to the static select menu, you can also
generate a data set for a menu on the fly. Make dialog
select menus more dynamic by specifying one of these four
data_source types:
(for options or options groups leave empty)
*/
+data_source: 'users' | 'channels' | 'conversations',
min_query_length?: number,
};
export type Slack$Dialog$Element$Select$DataSource$External = {|
...Slack$Dialog$Element$Select$Common,
+data_source: 'external',
/* Provides a default selected value for dynamic select
menus with a data_source of type external. This should
be an array containing a single object that specifies
the default label and value.
? To set default options for other types use value with
? the value of the options in the list
*/
selected_options?: [Slack$Dialog$Element$Select$Option],
min_query_length?: number,
|};
export type Slack$Dialog$Element$Select$Options = {|
...Slack$Dialog$Element$Select$Common,
/* Provide up to 100 options. Either options or
option_groups is required for the static and external.
options[].label is a user-facing string for this option.
75 characters maximum. Required.
options[].value is a string value for your app. If an
integer is used, it will be parsed as a string. 75
characters maximum. Required. */
+options: Array<Slack$Dialog$Element$Select$Option>,
|};
export type Slack$Dialog$Element$Select$OptionsGroups = {|
...Slack$Dialog$Element$Select$Common,
/* An array of objects containing a label and a list of
options. Provide up to 100 option groups. Either
options or option_groups is required for the static and
external.
options_groups[].label is a user-facing string for this
option. 75 characters maximum. Required.
options_groups[].options is an array that contains a list
of options. It is formatted like the options array (see
options). */
+options_groups: Array<Slack$Dialog$Element$Select$OptionGroup>,
|};
export type Bot$Message$Creator = ((...args: any[]) => Bot$Message) | Bot$Message;
export type Bot$Message$Field = {|
title: string,
value: string,
short?: boolean,
|};
export type Bot$Message$Attachment = {|
fallback?: string,
pretext?: string,
text?: string,
color?: string,
fields?: Array<void | Bot$Message$Field>,
footer?: string,
|};
export type Bot$Message = {|
response_type?: 'in_channel',
delete?: boolean,
ts?: string,
channel?: string,
text?: string,
pretext?: string,
link_names?: boolean,
attachments?: Array<void | Bot$Message$Attachment>,
footer?: string,
|};
type ValidationErrorType = {|
name: string,
error: string,
|};
export type Bot$Validator$Execute = () => { errors: Array<ValidationErrorType> } | void;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment