UNPKG

@atomist/automation-client

Version:

Atomist API for software low-level client

517 lines (447 loc) • 15.5 kB
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; }