@atomist/automation-client
Version:
Atomist API for software low-level client
517 lines (447 loc) • 15.5 kB
text/typescript
import {
Action,
SlackMessage,
} from "@atomist/slack-messages";
import { flatten } from "flat";
import * as _ from "lodash";
import { AnyOptions } from "../../configuration";
import { HandleCommand } from "../../HandleCommand";
import { HandlerContext } from "../../HandlerContext";
import { metadataFromInstance } from "../../internal/metadata/metadataReading";
import { Source } from "../../internal/transport/RequestProcessor";
import { ParameterType } from "../../SmartParameters";
import { lookupChatTeam } from "./MessageClientSupport";
/* tslint:disable:max-file-line-count */
/**
* Implemented by classes that can send bot messages, whether to
* channels or individuals, including actions and updates.
*/
export interface MessageClient {
/**
* Send a response back to where this command request originated.
* @param msg
* @param {MessageOptions} options
* @returns {Promise<any>}
*/
respond(msg: any, options?: MessageOptions): Promise<any>;
/**
* Send a message to any given destination.
* @param msg
* @param {Destination | Destination[]} destinations
* @param {MessageOptions} options
* @returns {Promise<any>}
*/
send(msg: any,
destinations: Destination | Destination[],
options?: MessageOptions): Promise<any>;
/**
* Optionally delete message that was previously sent.
* @param destinations
* @param id
*/
delete?(destinations: Destination | Destination[],
options: RequiredMessageOptions): Promise<void>;
}
/**
* MessageClient to send messages to the default Slack team.
*/
export interface SlackMessageClient {
/**
* Send a message to a Slack user
* @param {string | SlackMessage | SlackFileMessage} msg
* @param {string | string[]} users
* @param {MessageOptions} options
* @returns {Promise<any>}
*/
addressUsers(msg: string | SlackMessage | SlackFileMessage,
users: string | string[],
options?: MessageOptions): Promise<any>;
/**
* Send a message to a Slack channel
* @param {string | SlackMessage | SlackFileMessage} msg
* @param {string | string[]} channels
* @param {MessageOptions} options
* @returns {Promise<any>}
*/
addressChannels(msg: string | SlackMessage | SlackFileMessage,
channels: string | string[],
options?: MessageOptions): Promise<any>;
}
/**
* Basic message destination.
*/
export interface Destination {
/** Type of Destination. */
userAgent: string;
}
/**
* Message Destination for the Web App.
*/
export class WebDestination implements Destination {
public static WEB_USER_AGENT: string = "web";
public userAgent: string = WebDestination.WEB_USER_AGENT;
}
/**
* Message Destination for the Web App.
*/
export class SourceDestination implements Destination {
public static SOURCE_USER_AGENT: string = "source";
public userAgent: string = SourceDestination.SOURCE_USER_AGENT;
constructor(public readonly source: Source,
public readonly system: "slack" | "web") { }
}
export function addressWeb(): WebDestination {
return new WebDestination();
}
/**
* Message Destination for Slack.
*/
export class SlackDestination implements Destination {
public static SLACK_USER_AGENT: string = "slack";
public userAgent: string = SlackDestination.SLACK_USER_AGENT;
/** Slack user names to send message to. */
public users: string[] = [];
/** Slack channel names to send message to. */
public channels: string[] = [];
/**
* Create a Destination suitable for sending messages to a Slack
* workspace.
*
* @param team Slack workspace ID, which typically starts with the
* letter "T", consists of numbers and upper-case letters,
* and is nine characters long. It can be obtained by
* sending the Atomist Slack bot the message "team".
* @return {SlackDestination} A MessageClient suitable for sending messages.
*/
constructor(public team: string) { }
/**
* Address user by Slack user name. This method appends the
* provided user to a list of users that will be sent the message
* via this Destination. In other words, calling repeatedly with
* differing Slack user names results in the message being sent to
* all such users.
*
* @param {string} user Slack user name.
* @returns {SlackDestination} MessageClient Destination that results
* in message being send to user.
*/
public addressUser(user: string): SlackDestination {
this.users.push(user);
return this;
}
/**
* Address channel by Slack channel name. This method appends the
* provided channel to a list of channels that will be sent the
* message via this Destination. In other words, calling
* repeatedly with differing Slack channel names results in the
* message being sent to all such channels.
*
* @param {string} channel Slack channel name.
* @returns {SlackDestination} MessageClient Destination that results
* in message being send to channel.
*/
public addressChannel(channel: string): SlackDestination {
this.channels.push(channel);
return this;
}
}
/**
* Shortcut for creating a SlackDestination which addresses the given
* users.
*
* @param {string} team Slack workspace ID to create Destination for.
* @param {string} users Slack user names to send message to.
* @returns {SlackDestination} MessageClient Destination to pass to `send`.
*/
export function addressSlackUsers(team: string, ...users: string[]): SlackDestination {
const sd = new SlackDestination(team);
users.forEach(u => sd.addressUser(u));
return sd;
}
/**
* Shortcut for creating a SlackDestination which addresses the given
* users in all Slack teams connected to the context.
*
* @param {HandlerContext} ctx Handler context as passed to the Handler handle method.
* @param {string} users Slack user names to send message to.
* @returns {Promise<SlackDestination>} MessageClient Destination to pass to `send`.
*/
export function addressSlackUsersFromContext(ctx: HandlerContext, ...users: string[]): Promise<SlackDestination> {
return lookupChatTeam(ctx.graphClient)
.then(chatTeamId => {
return addressSlackUsers(chatTeamId, ...users);
});
}
/**
* Shortcut for creating a SlackDestination which addresses the given
* channels.
*
* @param {string} team Slack workspace ID to create Destination for.
* @param {string} channels Slack channel names to send messages to.
* @returns {SlackDestination} MessageClient Destination to pass to `send`.
*/
export function addressSlackChannels(team: string, ...channels: string[]): SlackDestination {
const sd = new SlackDestination(team);
channels.forEach(c => sd.addressChannel(c));
return sd;
}
/**
* Shortcut for creating a SlackDestination which addresses the given
* channels in all Slack teams connected to the context.
*
* @param {HandlerContext} ctx Handler context as passed to the Handler handle method.
* @param {string} channels Slack channel names to send messages to.
* @returns {Promise<SlackDestination>} MessageClient Destination to pass to `send`.
*/
export function addressSlackChannelsFromContext(ctx: HandlerContext, ...channels: string[]): Promise<SlackDestination> {
return lookupChatTeam(ctx.graphClient)
.then(chatTeamId => {
return addressSlackChannels(chatTeamId, ...channels);
});
}
/**
* Message Destination for Custom Event types.
*/
export class CustomEventDestination implements Destination {
public static INGESTER_USER_AGENT: string = "ingester";
public userAgent: string = CustomEventDestination.INGESTER_USER_AGENT;
/**
* Constructur returning a Destination for creating an instance of
* the Custom Event type `rootType`.
*/
constructor(public rootType: string) { }
}
/**
* Helper wrapping the constructor for CustomEventDestination.
*/
export function addressEvent(rootType: string): CustomEventDestination {
return new CustomEventDestination(rootType);
}
/**
* Message to create a Snippet in Slack
*/
export interface SlackFileMessage {
content: string;
title?: string;
fileName?: string;
// https://api.slack.com/types/file#file_types
fileType?: string;
comment?: string;
}
export type RequiredMessageOptions = Pick<MessageOptions, "id" | "thread"> & { id: string };
/**
* Options for sending messages using the MessageClient.
*/
export interface MessageOptions extends AnyOptions {
/**
* Unique message id per channel and team. This is required
* if you wish to re-write a message at a later time.
*/
id?: string;
/**
* Time to live for a posted message. If ts + ttl of the
* existing message with ts is < as a new incoming message
* with the same id, the message will be re-written.
*/
ttl?: number;
/**
* Timestamp of the message. The timestamp needs to be
* sortable lexicographically. Should be in milliseconds and
* defaults to Date.now().
*
* This is only applicable if id is set too.
*/
ts?: number;
/**
* If update_only is given, this message will only be posted
* if a previous message with the same id exists.
*/
post?: "update_only" | "always";
/**
* Optional thread identifier to send this message to or true to send
* this to the message that triggered this command.
*/
thread?: string | boolean;
}
/** Valid MessageClient types. */
export const MessageMimeTypes = {
SLACK_JSON: "application/x-atomist-slack+json",
SLACK_FILE_JSON: "application/x-atomist-slack-file+json",
PLAIN_TEXT: "text/plain",
APPLICATION_JSON: "application/json",
};
export interface CommandReferencingAction extends Action {
command: CommandReference;
}
/**
* Information about a command handler used to connect message actions
* to a command.
*/
export interface CommandReference {
/**
* The id of the action as referenced in the markup.
*/
id: string;
/**
* The name of the command the button or menu should invoke
* when selected.
*/
name: string;
/**
* List of parameters to be passed to the command.
*/
parameters?: { [key: string]: any };
/**
* Name of the parameter that should be used to pass the values
* of the menu drop-down.
*/
parameterName?: string;
}
/**
* Create a slack button that invokes a command handler.
*/
export function buttonForCommand(buttonSpec: ButtonSpecification,
command: string | HandleCommand,
parameters: ParameterType = {}): Action {
const cmd = commandName(command);
const params = mergeParameters(command, parameters);
const id = cmd.toLocaleLowerCase();
const action = chatButtonFrom(buttonSpec, { id }) as CommandReferencingAction;
action.command = {
id,
name: cmd,
parameters: params,
};
return action;
}
/**
* Create a Slack menu that invokes a command handler.
*/
export function menuForCommand(selectSpec: MenuSpecification,
command: string | HandleCommand,
parameterName: string,
parameters: ParameterType = {}): Action {
const cmd = commandName(command);
const params = mergeParameters(command, parameters);
const id = cmd.toLocaleLowerCase();
const action = chatMenuFrom(selectSpec, { id, parameterName }) as CommandReferencingAction;
action.command = {
id,
name: cmd,
parameters: params,
parameterName,
};
return action;
}
/**
* Check if the object is a valid Slack message.
*/
export function isSlackMessage(object: any): object is SlackMessage {
return !!object && (object.text || object.attachments) && !object.content;
}
/**
* Check if the object is a valid Slack file message, i.e., a snippet.
*/
export function isFileMessage(object: any): object is SlackFileMessage {
return !!object && !object.length && object.content;
}
/**
* Extract command name from the argument.
*/
export function commandName(command: any): string {
try {
if (typeof command === "string") {
return command;
} else if (typeof command === "function") {
return command.prototype.constructor.name;
} else {
return metadataFromInstance(command).name;
}
} catch (e) {
throw new Error("Unable to determine the name of this command. " +
"Please pass the name as a string or an instance of the command");
}
}
/**
* Merge the provided parameters into any parameters provided as
* command object instance variables.
*/
export function mergeParameters(command: any, parameters: any): any {
// Reuse parameters defined on the instance
if (typeof command !== "string" && typeof command !== "function") {
const newParameters = _.merge(command, parameters);
return flatten(newParameters);
}
return flatten(parameters);
}
function chatButtonFrom(action: ButtonSpecification, command: any): Action {
if (!command.id) {
throw new Error(`Please provide a valid non-empty command id`);
}
const button: Action = {
text: action.text,
type: "button",
name: `automation-command::${command.id}`,
};
_.forOwn(action, (v, k) => {
(button as any)[k] = v;
});
return button;
}
function chatMenuFrom(action: MenuSpecification, command: any): Action {
if (!command.id) {
throw new Error("SelectableIdentifiableInstruction must have id set");
}
if (!command.parameterName) {
throw new Error("SelectableIdentifiableInstruction must have parameterName set");
}
const select: Action = {
text: action.text,
type: "select",
name: `automation-command::${command.id}`,
};
if (typeof action.options === "string") {
select.data_source = action.options;
} else if (action.options.length > 0) {
const first = action.options[0] as any;
if (first.value) {
// then it's normal options
select.options = action.options as SelectOption[];
} else {
// then it's option groups
select.option_groups = action.options as OptionGroup[];
}
}
_.forOwn(action, (v, k) => {
if (k !== "options") {
(select as any)[k] = v;
}
});
return select;
}
export interface ActionConfirmation {
title?: string;
text: string;
ok_text?: string;
dismiss_text?: string;
}
export interface ButtonSpecification {
text: string;
style?: string;
confirm?: ActionConfirmation;
role?: string;
}
export interface SelectOption {
text: string;
value: string;
}
export interface OptionGroup {
text: string;
options: SelectOption[];
}
export type DataSource = "static" | "users" | "channels" | "conversations" | "external";
export interface MenuSpecification {
text: string;
options: SelectOption[] | DataSource | OptionGroup[];
role?: string;
}