@atomist/automation-client
Version:
Atomist API for software low-level client
368 lines (319 loc) • 12.8 kB
text/typescript
import {
render,
SlackMessage,
} from "@atomist/slack-messages";
import * as _ from "lodash";
import { Config } from "winston/lib/winston/config";
import * as WebSocket from "ws";
import { Configuration } from "../../../configuration";
import {
CommandReferencingAction,
CustomEventDestination,
Destination,
isFileMessage,
isSlackMessage,
MessageMimeTypes,
MessageOptions,
RequiredMessageOptions,
SlackDestination,
SourceDestination,
WebDestination,
} from "../../../spi/message/MessageClient";
import { MessageClientSupport } from "../../../spi/message/MessageClientSupport";
import { logger } from "../../../util/logger";
import { redact } from "../../../util/redact";
import {
guid,
replacer,
toStringArray,
} from "../../util/string";
import {
CommandIncoming,
EventIncoming,
isCommandIncoming,
isEventIncoming,
Source,
} from "../RequestProcessor";
import { WebSocketLifecycle } from "./WebSocketLifecycle";
export abstract class AbstractMessageClient extends MessageClientSupport {
constructor(protected readonly request: CommandIncoming | EventIncoming,
protected readonly correlationId: string,
protected readonly team: { id: string, name?: string },
protected readonly source: Source,
protected readonly configuration: Configuration) {
super();
}
public async delete(destinations: Destination | Destination[],
options: RequiredMessageOptions): Promise<void> {
return this.doSend(undefined, destinations, { ...options, delete: true });
}
protected async doSend(msg: string | SlackMessage,
destinations: Destination | Destination[],
options: MessageOptions = {}): Promise<any> {
if (!!msg && (msg as HandlerResponse).content_type === "application/x-atomist-continuation+json") {
return this.sendResponse(msg).then(() => msg);
}
const ts = this.ts(options);
if (!Array.isArray(destinations)) {
destinations = [destinations];
}
let destinationIdentifier: "slack" | "ingester" | "web";
const responseDestinations = [];
let thread_ts;
if (options.thread === true && !!this.source) {
thread_ts = _.get(this.source, "slack.message.ts");
} else if (typeof options.thread === "string") {
thread_ts = options.thread;
}
destinations.forEach(d => {
if (d.userAgent === SlackDestination.SLACK_USER_AGENT) {
destinationIdentifier = "slack";
const sd = d as SlackDestination;
toStringArray(sd.channels).filter(c => !!c).forEach(c => {
responseDestinations.push({
user_agent: SlackDestination.SLACK_USER_AGENT,
slack: {
team: {
id: sd.team,
},
channel: {
name: c,
},
thread_ts,
},
});
});
toStringArray(sd.users).filter(u => !!u).forEach(u => {
responseDestinations.push({
user_agent: SlackDestination.SLACK_USER_AGENT,
slack: {
team: {
id: sd.team,
},
user: {
name: u,
},
thread_ts,
},
});
});
} else if (d.userAgent === CustomEventDestination.INGESTER_USER_AGENT) {
destinationIdentifier = "ingester";
responseDestinations.push({
user_agent: CustomEventDestination.INGESTER_USER_AGENT,
ingester: {
root_type: (d as CustomEventDestination).rootType,
},
});
} else if (d.userAgent === WebDestination.WEB_USER_AGENT) {
destinationIdentifier = "web";
} else if (d.userAgent === SourceDestination.SOURCE_USER_AGENT) {
destinationIdentifier = (d as SourceDestination).system;
responseDestinations.push((d as SourceDestination).source);
}
});
if (responseDestinations.length === 0 && this.source) {
// TODO CD this is probably not always going to be valid
destinationIdentifier = "slack";
const responseDestination = _.cloneDeep(this.source);
if (responseDestination.slack) {
delete responseDestination.slack.user;
if (!!thread_ts) {
responseDestination.slack.thread_ts = thread_ts;
}
}
responseDestinations.push(responseDestination);
}
const response: HandlerResponse = {
api_version: "1",
correlation_id: this.correlationId,
team: this.team,
source: this.source ? this.source : undefined,
command: isCommandIncoming(this.request) ? this.request.command : undefined,
event: isEventIncoming(this.request) ? this.request.extensions.operationName : undefined,
destinations: responseDestinations,
id: options.id ? options.id : undefined,
timestamp: ts,
ttl: ts && options.ttl ? options.ttl : undefined,
post_mode: options.post === "update_only" ? "update_only" : (options.post === "always" ? "always" : "ttl"),
};
if (destinationIdentifier === "web") {
return Promise.resolve();
} else if (destinationIdentifier === "slack") {
if (isSlackMessage(msg)) {
const msgClone = _.cloneDeep(msg);
const actions = mapActions(msgClone);
response.content_type = MessageMimeTypes.SLACK_JSON;
response.body = render(msgClone, false);
response.actions = actions;
} else if (isFileMessage(msg)) {
response.content_type = MessageMimeTypes.SLACK_FILE_JSON;
response.body = JSON.stringify({
content: msg.content,
filename: msg.fileName,
filetype: msg.fileType,
title: msg.title,
initial_comment: msg.comment,
});
} else if (typeof msg === "string") {
response.content_type = MessageMimeTypes.PLAIN_TEXT;
response.body = msg;
} else if (!!options.delete) {
response.content_type = "application/x-atomist-delete";
response.body === undefined;
}
if (_.get(this.configuration, "redact.messages", true) === true) {
response.body = redact(response.body);
}
} else if (destinationIdentifier === "ingester") {
response.content_type = MessageMimeTypes.APPLICATION_JSON;
response.body = JSON.stringify(msg);
response.id = (options.id ? options.id : guid());
}
return this.sendResponse(response).then(() => response);
}
protected abstract sendResponse(response: any): Promise<void>;
private ts(options: MessageOptions): number {
if (options.id) {
if (options.ts) {
return options.ts;
} else {
return Date.now();
}
} else {
return undefined;
}
}
}
export class AbstractWebSocketMessageClient extends AbstractMessageClient {
constructor(protected readonly ws: WebSocketLifecycle,
protected readonly request: CommandIncoming | EventIncoming,
protected readonly correlationId: string,
protected readonly team: { id: string, name?: string },
protected readonly source: Source,
protected readonly configuration: Configuration) {
super(request, correlationId, team, source, configuration);
}
protected async sendResponse(response: any): Promise<void> {
this.ws.send(response);
}
}
export class WebSocketCommandMessageClient extends AbstractWebSocketMessageClient {
constructor(request: CommandIncoming, ws: WebSocketLifecycle, configuration: Configuration) {
super(ws, request, request.correlation_id, request.team, request.source, configuration);
}
protected async doSend(msg: string | SlackMessage,
destinations: Destination | Destination[],
options: MessageOptions = {}): Promise<any> {
return super.doSend(msg, destinations, options);
}
}
export class WebSocketEventMessageClient extends AbstractWebSocketMessageClient {
constructor(request: EventIncoming, ws: WebSocketLifecycle, configuration: Configuration) {
super(ws, request, request.extensions.correlation_id,
{ id: request.extensions.team_id, name: request.extensions.team_name }, null, configuration);
}
protected async doSend(msg: string | SlackMessage,
destinations: Destination | Destination[],
options: MessageOptions = {}): Promise<any> {
if (!Array.isArray(destinations)) {
destinations = [destinations];
}
if (destinations.length === 0) {
throw new Error("Response messages are not supported for event handlers");
} else {
return super.doSend(msg, destinations, options);
}
}
}
export function mapActions(msg: SlackMessage): Action[] {
const actions: Action[] = [];
let counter = 0;
if (msg.attachments) {
msg.attachments.filter(attachment => attachment.actions).forEach(attachment => {
attachment.actions.forEach(a => {
if (!!a && !!(a as CommandReferencingAction).command) {
const cra = a as CommandReferencingAction;
const id = counter++;
cra.command.id = `${cra.command.id}-${id}`;
a.name = `${a.name}-${id}`;
const action: Action = {
id: cra.command.id,
parameter_name: cra.command.parameterName,
command: cra.command.name,
parameters: mapParameters(cra.command.parameters),
};
actions.push(action);
// Lastly we need to delete our extension from the slack action
cra.command = undefined;
}
});
});
return actions;
}
}
function mapParameters(data: {}): Parameter[] {
const parameters: Parameter[] = [];
for (const key in data) {
if (data.hasOwnProperty(key)) {
const value = data[key];
if (value) {
parameters.push({
name: key,
value: value.toString(),
});
} else {
// logger.debug(`Parameter value for '${key}' is null`);
}
}
}
return parameters;
}
export function sendMessage(message: any, ws: WebSocket, log: boolean = true): void {
if (log) {
logger.debug(`Sending message '${JSON.stringify(message, replacer)}'`);
}
ws.send(JSON.stringify(message));
}
export function clean(addresses: string[] | string): string[] {
let na: string[] = toStringArray(addresses);
if (na) {
// Filter out any null addresses
na = na.filter(nad => nad !== null && nad.length > 0);
}
return na;
}
export interface HandlerResponse {
api_version: "1";
correlation_id: any;
team: {
id: string;
name?: string;
};
command?: string;
event?: string;
status?: {
code: number;
reason: string;
};
source?: Source;
destinations?: any[];
content_type?: string;
body?: string;
// Updatable messages
id?: string;
timestamp?: number;
ttl?: number;
post_mode?: "ttl" | "always" | "update_only";
actions?: Action[];
}
export interface Action {
id: string;
parameter_name?: string;
command: string;
parameters: Parameter[];
}
export interface Parameter {
name: string;
value: string;
}