@broid/kit
Version:
Bot framework supported all messaging plateforms and middlewares.
462 lines (391 loc) • 15.4 kB
text/typescript
import {
IActivityStream,
ISendParameters,
} from '@broid/schemas';
import { Logger } from '@broid/utils';
import * as Promise from 'bluebird';
import * as bodyParser from 'body-parser';
import * as express from 'express';
import * as http from 'http';
import * as R from 'ramda';
import { Observable } from 'rxjs/Rx';
import {
callbackType,
IHTTPOptions,
IListenerArgs,
IMetaMediaSend,
IOptions,
middlewareIncomingType,
middlewareOutgoingType,
} from './interfaces';
const isObservable = (obs: any): boolean => obs && typeof obs.subscribe === 'function';
const isPromise = (obj: any): boolean =>
obj && (typeof obj === 'object')
&& ('tap' in obj) && ('then' in obj) && (typeof obj.then === 'function');
export class Bot {
public httpEndpoints: string[];
public httpServer: http.Server | null;
private router: express.Router;
public integrations: any;
private logLevel: string;
private logger: Logger;
private outgoingMiddlewares: any;
private incomingMiddlewares: any;
constructor(obj?: IOptions) {
this.logLevel = obj && obj.logLevel || 'info';
this.integrations = [];
this.incomingMiddlewares = [];
this.outgoingMiddlewares = [];
this.router = express.Router();
this.httpEndpoints = [];
this.httpServer = null;
if (obj && obj.http) {
this.startHttpServer(obj.http);
}
this.logger = new Logger('broidkit', this.logLevel);
}
public getHTTPEndpoints(): string[] {
return this.httpEndpoints;
}
public getRouter(): express.Router | null {
if (this.httpServer) {
return null;
}
return this.router;
}
public use(instance: any, filter?: string | string[]): void {
// it's an integration
if (instance.listen) {
this.logger.info({ method: 'use', message: `Integration: ${instance.serviceName()}` });
this.addIntegration(instance);
} else if (instance.incoming) {
this.logger
.info({ method: 'use', message: `incoming middleware: ${instance.serviceName()}` });
this.incomingMiddlewares.push({
filter: filter || null,
middleware: instance,
name: `${instance.serviceName()}.incoming`,
});
} else if (instance.outgoing) { // Middleware
this.logger
.info({ method: 'use', message: `outgoing middleware: ${instance.serviceName()}` });
this.outgoingMiddlewares.push({
filter: filter || null,
middleware: instance,
name: `${instance.serviceName()}.outgoing`,
});
}
return;
}
// messageTypes => Image, Video, Group, Private, Mention etc...
public hear(pattern: string | boolean,
messageTypes?: string | callbackType,
cb?: callbackType): Observable<IActivityStream> {
const args: IListenerArgs = this.processArgs(messageTypes, cb);
const messageTypesArr: string[] = this.messageTypes2Arr(R.prop('msgTypes', args) as string);
let patternRegex: boolean | RegExp = false;
if (typeof(pattern) === 'string') {
patternRegex = new RegExp(pattern as string, 'ig');
} else {
patternRegex = pattern as boolean;
}
const listener: Observable<IActivityStream> = Observable
.merge(...R.flatten(R.map((integration: any) =>
[integration.connect(), integration.listen()], this.integrations)))
.mergeMap((message: IActivityStream) => this.processIncomingMessage(message))
.mergeMap((messageUpdated: any) =>
this.testIncoming(messageUpdated.message, patternRegex, messageTypesArr)
? Promise.resolve(messageUpdated) : Observable.empty());
return this.processListener(listener, R.prop('callback', args) as callbackType);
}
public hears(patterns: string[],
messageTypes?: string | callbackType,
cb?: callbackType): Observable<IActivityStream> {
const args: IListenerArgs = this.processArgs(messageTypes, cb);
const messageTypesArr: string[] = this.messageTypes2Arr(R.prop('msgTypes', args) as string);
const patternRegexes: RegExp[] = R.map((pattern: string) =>
new RegExp(pattern, 'ig'), patterns);
const listener: Observable<IActivityStream> = Observable
.merge(...R.flatten(R.map((integration: any) =>
[integration.connect(), integration.listen()], this.integrations)))
.mergeMap((message: IActivityStream) => this.processIncomingMessage(message))
.mergeMap((messageUpdated: any) => {
const matches = R.pipe(R.map((patternRegex: RegExp) =>
this.testIncoming(messageUpdated.message, patternRegex, messageTypesArr)),
R.reject(R.equals(false)));
if (!R.isEmpty(matches(patternRegexes))) {
return Promise.resolve(messageUpdated);
}
return Observable.empty();
});
return this.processListener(listener, R.prop('callback', args) as callbackType);
}
public on(messageTypes?: string | callbackType,
cb?: callbackType): Observable<IActivityStream> {
return this.hear(true, messageTypes, cb);
}
public sendText(text: string, message: IActivityStream) {
return this.processOutgoingContent(text, message)
.then((updated) => {
const content: string = updated.content || text;
let data: ISendParameters = {
'@context': 'https://www.w3.org/ns/activitystreams',
'generator': {
id: R.path(['generator', 'id'], message),
name: R.path(['generator', 'name'], message),
type: 'Service',
},
'object': {
content,
id: R.path(['object', 'id'], message),
type: 'Note',
},
'to': {
id: R.path(['target', 'id'], message),
type: R.path(['target', 'type'], message),
},
'type': 'Create',
};
data = this.addMessageContext(data, message);
return this.send(data);
});
}
public sendVideo(url: string, message: IActivityStream, meta?: IMetaMediaSend) {
return this.sendMedia(url, 'Video', message, meta);
}
public sendImage(url: string, message: IActivityStream, meta?: IMetaMediaSend) {
return this.sendMedia(url, 'Image', message, meta);
}
private processOutgoingContent(content: string, message: IActivityStream): Promise<any> {
return this.processOutgoingMessage(content, message)
.toPromise(Promise)
.then((updated) => {
const contents = R.reject(R.isNil)(R.map((o: any) => o.content, updated.data));
if (!R.isEmpty(contents)) {
updated.content = R.join(' ', contents);
}
return updated;
});
}
private messageTypes2Arr(messageTypes?: string | null): string[] {
let messageTypesArr: string[] = [];
if (messageTypes) {
messageTypesArr = R.map((m) =>
R.toLower(m.replace(/^\s+|\s+$/g, '')), R.split(',', messageTypes));
}
return messageTypesArr;
}
private processArgs(msgTypes?: string | callbackType, cb?: callbackType): IListenerArgs {
if (R.is(Function, msgTypes)) {
return {
callback: msgTypes as callbackType,
};
}
return {
callback: cb,
msgTypes: msgTypes as string,
};
}
private processListener(listener: Observable<IActivityStream>,
callback?: callbackType): Observable<IActivityStream> {
if (callback) {
listener.subscribe(callback, (error) => callback(null, error));
}
return listener;
}
private testIncoming(message: IActivityStream,
patternRegex: RegExp | boolean,
messageTypesArr: string[]): boolean {
const messageContext = R.prop('@context', message);
if (!messageContext) {
this.logger.debug('Message incoming should follow Broid schema.', message);
return false;
}
const content = R.path(['object', 'content'], message);
const targetType = R.toLower(R.path(['target', 'type'], message) as string);
if (R.isEmpty(messageTypesArr) || R.contains(targetType, messageTypesArr)) {
if (patternRegex instanceof RegExp) {
const isMatch = patternRegex.test(content as string);
// FIX: http://stackoverflow.com/questions/18462784/why-is-javascript-regex-matching-every-second-time
patternRegex.lastIndex = 0;
if (isMatch === true) {
return true;
}
} else if (patternRegex === true) {
return true;
}
}
return false;
}
private send(data: ISendParameters): Promise<any> {
const to = R.path(['to', 'id'], data);
const toType = R.path(['to', 'type'], data);
const serviceID = R.path(['generator', 'id'], data);
const serviceName = R.path(['generator', 'name'], data);
if (to && toType && serviceID && serviceName) {
const integrationFind = R.filter((integration: any) =>
integration.serviceId() === serviceID, this.integrations);
if (!R.isEmpty(integrationFind)) {
return integrationFind[0].send(data);
}
return Promise.reject(`Integration ${serviceID} not found.`);
}
return Promise.reject('Message should follow broid-schemas.');
}
private sendMedia(url: string, mediaType: string,
message: IActivityStream,
meta: IMetaMediaSend = {}): Promise<any> {
return this.processOutgoingContent(url, message)
.then((updated: any) => {
const urlUpdated: string = updated.content || url;
let data: ISendParameters = {
'@context': 'https://www.w3.org/ns/activitystreams',
'generator': {
id: R.path(['generator', 'id'], message),
name: R.path(['generator', 'name'], message),
type: 'Service',
},
'object': {
content: R.prop('content', meta) || '',
id: R.path(['object', 'id'], message),
name: R.prop('name', meta) || '',
title: R.prop('title', meta) || '',
type: mediaType,
url: urlUpdated,
},
'to': {
id: R.path(['target', 'id'], message),
type: R.path(['target', 'type'], message),
},
'type': 'Create',
};
data = this.addMessageContext(data, message);
return this.send(data);
});
}
private addIntegration(integration: any): void {
this.integrations.push(integration);
if (!integration.getRouter) {
return;
}
const router = integration.getRouter();
if (router) {
const httpPath = `/webhook/${integration.serviceName()}`;
this.httpEndpoints.push(httpPath);
this.router.use(httpPath, router);
}
return;
}
/**
* I'd like to identify I way reach the same results dynamically, given an array (or sequence) of filters.
*
* @param input {} A value to be processed by a chain of filters.
* @param filters {Array} An array of filters through which to process the input.
* @returns {Observable} The output after processing `input` through the chained filters.
*/
private chain(input, filters) {
const seq = Observable.from(filters);
return seq.reduce(
(chain: any, filter: any, index: any) => {
return chain.concatMap((data: any) => {
return filter(data)
.map((filterResult: any) => {
return R.flatten(R.concat(data, [R.assoc('order', index, filterResult)]));
});
});
},
Observable.of(input),
)
.concatMap((value: any) => value);
}
private processIncomingMessage(message: IActivityStream): Observable<any> {
const middlewares = R.map((middleware: any) => {
return (acc: any) => {
let resultObservable = Observable.empty();
// Filter by regex if it' set
let patternRegexes: boolean[] | RegExp[] = [];
if (middleware.filter) {
const patterns = R.is(Array, middleware.filter) ? middleware.filter : [middleware.filter];
patternRegexes = R.map((pattern: string) => new RegExp(pattern, 'ig'), patterns);
}
const matches = R.pipe(R.map((patternRegex: RegExp | boolean) =>
this.testIncoming(message, patternRegex, [])),
R.reject(R.equals(false)));
if (R.isEmpty(patternRegexes) || !R.isEmpty(matches(patternRegexes))) {
const fn: middlewareIncomingType = middleware.middleware.incoming;
const result: any = fn(this, message, acc);
if (isObservable(result)) {
resultObservable = result;
} else if (isPromise(result)) {
resultObservable = Observable.fromPromise(result);
} else {
resultObservable = Observable.of(result);
}
}
return resultObservable.map((data) => ({ middleware: middleware.name, data }));
};
}, this.incomingMiddlewares);
const intialAcc = [];
return this.chain(intialAcc, middlewares)
.take(1)
.map((data: any) => ({ data, message }));
}
private processOutgoingMessage(content: string, message: IActivityStream): Observable<any> {
const middlewares = R.map((middleware: any) => {
return (acc: any) => {
let resultObservable = Observable.empty();
// Filter by regex if it' set
let patternRegexes: boolean[] | RegExp[] = [];
if (middleware.filter) {
const patterns = R.is(Array, middleware.filter) ? middleware.filter : [middleware.filter];
patternRegexes = R.map((pattern: string) => new RegExp(pattern, 'ig'), patterns);
}
const matches = R.pipe(R.map((patternRegex: RegExp | boolean) =>
this.testIncoming(message, patternRegex, [])),
R.reject(R.equals(false)));
if (R.isEmpty(patternRegexes) || !R.isEmpty(matches(patternRegexes))) {
const fn: middlewareOutgoingType = middleware.middleware.outgoing;
const result: any = fn(this, content, message, acc);
if (isObservable(result)) {
resultObservable = result;
} else if (isPromise(result)) {
resultObservable = Observable.fromPromise(result);
} else {
resultObservable = Observable.of(result);
}
}
return resultObservable.map((d: any) => {
let data: any = d;
if (typeof data === 'string') {
data = {
content: data,
};
}
return { middleware: middleware.name, data, content: data.content };
});
};
}, this.outgoingMiddlewares);
const intialAcc = [];
return this.chain(intialAcc, middlewares)
.take(1)
.map((data: any) => ({ data, message }));
}
private startHttpServer(httpOptions: IHTTPOptions): void {
if (!this.httpServer) {
const app: express = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(this.router);
this.httpServer = app.listen(httpOptions.port, httpOptions.host, () => {
this.logger.info(`Server listening on port ${httpOptions.host}:${httpOptions.port}...`);
});
}
}
private addMessageContext(data: ISendParameters, message: IActivityStream): ISendParameters {
const context = R.path(['object', 'context'], message);
if (context) {
data.object = R.assoc('context', context, data.object);
}
return data;
}
}