From ca0793574abd4cbdab008043669790e92154b661 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sat, 29 May 2021 16:34:24 -0400 Subject: [PATCH] :sparkles: Add AWS transcribe node (#1826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Aws Transcribe node * :zap: Improvements to #1801 * :zap: Small fix * :pencil2: Edit node param descriptions * :zap: Set missing defaults * :zap: Fix duplicate description * :zap: Set integer limit values * :zap: Improvements * :zap: Fix name Co-authored-by: Alexander Mustafin Co-authored-by: Iván Ovejero Co-authored-by: Jan Oberhauser --- .../Aws/Transcribe/AwsTranscribe.node.json | 20 + .../Aws/Transcribe/AwsTranscribe.node.ts | 544 ++++++++++++++++++ .../nodes/Aws/Transcribe/GenericFunctions.ts | 107 ++++ .../nodes/Aws/Transcribe/transcribe.svg | 1 + packages/nodes-base/package.json | 1 + 5 files changed, 673 insertions(+) create mode 100644 packages/nodes-base/nodes/Aws/Transcribe/AwsTranscribe.node.json create mode 100644 packages/nodes-base/nodes/Aws/Transcribe/AwsTranscribe.node.ts create mode 100644 packages/nodes-base/nodes/Aws/Transcribe/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Aws/Transcribe/transcribe.svg diff --git a/packages/nodes-base/nodes/Aws/Transcribe/AwsTranscribe.node.json b/packages/nodes-base/nodes/Aws/Transcribe/AwsTranscribe.node.json new file mode 100644 index 0000000000..314e6f04da --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Transcribe/AwsTranscribe.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.awsTranscribe", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/aws" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.awsTranscribe/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Aws/Transcribe/AwsTranscribe.node.ts b/packages/nodes-base/nodes/Aws/Transcribe/AwsTranscribe.node.ts new file mode 100644 index 0000000000..aa81b2d4ff --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Transcribe/AwsTranscribe.node.ts @@ -0,0 +1,544 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + awsApiRequestREST, + awsApiRequestRESTAllItems, +} from './GenericFunctions'; + +export class AwsTranscribe implements INodeType { + description: INodeTypeDescription = { + displayName: 'AWS Transcribe', + name: 'awsTranscribe', + icon: 'file:transcribe.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Sends data to AWS Transcribe', + defaults: { + name: 'AWS Transcribe', + color: '#5aa08d', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'aws', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Transcription Job', + value: 'transcriptionJob', + }, + ], + default: 'transcriptionJob', + description: 'Resource to operate on.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a transcription job', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a transcription job', + }, + { + name: 'Get', + value: 'get', + description: 'Get a transcription job', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all transcription jobs', + }, + ], + default: 'create', + description: 'Operation to perform.', + }, + { + displayName: 'Job Name', + name: 'transcriptionJobName', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'transcriptionJob', + ], + operation: [ + 'create', + 'get', + 'delete', + ], + }, + }, + description: 'The name of the job.', + }, + { + displayName: 'Media File URI', + name: 'mediaFileUri', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'transcriptionJob', + ], + operation: [ + 'create', + ], + }, + }, + description: 'The S3 object location of the input media file. ', + }, + { + displayName: 'Detect Language', + name: 'detectLanguage', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'transcriptionJob', + ], + operation: [ + 'create', + ], + }, + }, + default: false, + description: 'Set this field to true to enable automatic language identification.', + }, + { + displayName: 'Language', + name: 'languageCode', + type: 'options', + options: [ + { + name: 'American English', + value: 'en-US', + }, + { + name: 'British English', + value: 'en-GB', + }, + { + name: 'Irish English', + value: 'en-IE', + }, + { + name: 'Indian English', + value: 'en-IN', + }, + { + name: 'Spanish', + value: 'es-ES', + }, + { + name: 'German', + value: 'de-DE', + }, + { + name: 'Russian', + value: 'ru-RU', + }, + ], + displayOptions: { + show: { + resource: [ + 'transcriptionJob', + ], + operation: [ + 'create', + ], + detectLanguage: [ + false, + ], + }, + }, + default: 'en-US', + description: 'Language used in the input media file.', + }, + // ---------------------------------- + // Transcription Job Settings + // ---------------------------------- + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + displayOptions: { + show: { + operation: [ + 'create', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Channel Identification', + name: 'channelIdentification', + type: 'boolean', + default: false, + description: `Instructs Amazon Transcribe to process each audiochannel separately
+ and then merge the transcription output of each channel into a single transcription. + You can't set both Max Speaker Labels and Channel Identification in the same request. + If you set both, your request returns a BadRequestException.`, + }, + { + displayName: 'Max Alternatives', + name: 'maxAlternatives', + type: 'number', + default: 2, + typeOptions: { + minValue: 2, + maxValue: 10, + }, + description: 'The number of alternative transcriptions that the service should return.', + }, + { + displayName: 'Max Speaker Labels', + name: 'maxSpeakerLabels', + type: 'number', + default: 2, + typeOptions: { + minValue: 2, + maxValue: 10, + }, + description: `The maximum number of speakers to identify in the input audio.
+ If there are more speakers in the audio than this number, multiple speakers are
+ identified as a single speaker.`, + }, + { + displayName: 'Vocabulary Name', + name: 'vocabularyName', + type: 'string', + default: '', + description: 'Name of vocabulary to use when processing the transcription job.', + }, + { + displayName: 'Vocabulary Filter Name', + name: 'vocabularyFilterName', + type: 'string', + default: '', + description: `The name of the vocabulary filter to use when transcribing the audio.
+ The filter that you specify must have the same language code as the transcription job.`, + }, + { + displayName: 'Vocabulary Filter Method', + name: 'vocabularyFilterMethod', + type: 'options', + options: [ + { + name: 'Remove', + value: 'remove', + }, + { + name: 'Mask', + value: 'mask', + }, + { + name: 'Tag', + value: 'tag', + }, + + ], + default: 'remove', + description: `Set to mask to remove filtered text from the transcript and replace it with three asterisks ("***") as placeholder text.
+ Set to remove to remove filtered text from the transcript without using placeholder text. Set to tag to mark the word in the transcription
+ output that matches the vocabulary filter. When you set the filter method to tag, the words matching your vocabulary filter are not masked or removed.`, + }, + ], + }, + { + displayName: 'Return Transcript', + name: 'returnTranscript', + type: 'boolean', + default: true, + displayOptions: { + show: { + resource: [ + 'transcriptionJob', + ], + operation: [ + 'get', + ], + }, + }, + description: 'By default, the response only contains metadata about the transcript.
Enable this option to retrieve the transcript instead.', + }, + { + displayName: 'Simple', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'transcriptionJob', + ], + operation: [ + 'get', + ], + returnTranscript: [ + true, + ], + }, + }, + default: true, + description: 'Return a simplified version of the response instead of the raw data.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'transcriptionJob', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 20, + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'transcriptionJob', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + description: 'The maximum number of results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'transcriptionJob', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Job Name Contains', + name: 'jobNameContains', + type: 'string', + description: 'Return only transcription jobs whose name contains the specified string.', + default: '', + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Completed', + value: 'COMPLETED', + }, + { + name: 'Failed', + value: 'FAILED', + }, + { + name: 'In Progress', + value: 'IN_PROGRESS', + }, + { + name: 'Queued', + value: 'QUEUED', + }, + ], + description: 'Return only transcription jobs with the specified status.', + default: 'COMPLETED', + }, + ], + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < items.length; i++) { + if (resource === 'transcriptionJob') { + //https://docs.aws.amazon.com/comprehend/latest/dg/API_DetectDominantLanguage.html + if (operation === 'create') { + const transcriptionJobName = this.getNodeParameter('transcriptionJobName', i) as string; + const mediaFileUri = this.getNodeParameter('mediaFileUri', i) as string; + const detectLang = this.getNodeParameter('detectLanguage', i) as boolean; + + const options = this.getNodeParameter('options', i, {}) as IDataObject; + + const body: IDataObject = { + TranscriptionJobName: transcriptionJobName, + Media: { + MediaFileUri: mediaFileUri, + }, + }; + + if (detectLang) { + body.IdentifyLanguage = detectLang; + } else { + body.LanguageCode = this.getNodeParameter('languageCode', i) as string; + } + + if (options.channelIdentification) { + Object.assign(body.Settings, { ChannelIdentification: options.channelIdentification }); + } + + if (options.MaxAlternatives) { + Object.assign(body.Settings, { + ShowAlternatives: options.maxAlternatives, + MaxAlternatives: options.maxAlternatives, + }); + } + + if (options.showSpeakerLabels) { + Object.assign(body.Settings, { + ShowSpeakerLabels: options.showSpeakerLabels, + MaxSpeakerLabels: options.maxSpeakerLabels, + }); + } + + if (options.vocabularyName) { + Object.assign(body.Settings, { + VocabularyName: options.vocabularyName, + }); + } + + if (options.vocabularyFilterName) { + Object.assign(body.Settings, { + VocabularyFilterName: options.vocabularyFilterName, + }); + } + + if (options.vocabularyFilterMethod) { + Object.assign(body.Settings, { + VocabularyFilterMethod: options.vocabularyFilterMethod, + }); + } + + const action = 'Transcribe.StartTranscriptionJob'; + responseData = await awsApiRequestREST.call(this, 'transcribe', 'POST', '', JSON.stringify(body), { 'x-amz-target': action, 'Content-Type': 'application/x-amz-json-1.1' }); + responseData = responseData.TranscriptionJob; + } + //https://docs.aws.amazon.com/transcribe/latest/dg/API_DeleteTranscriptionJob.html + if (operation === 'delete') { + const transcriptionJobName = this.getNodeParameter('transcriptionJobName', i) as string; + + const body: IDataObject = { + TranscriptionJobName: transcriptionJobName, + }; + + const action = 'Transcribe.DeleteTranscriptionJob'; + responseData = await awsApiRequestREST.call(this, 'transcribe', 'POST', '', JSON.stringify(body), { 'x-amz-target': action, 'Content-Type': 'application/x-amz-json-1.1' }); + responseData = { success: true }; + } + //https://docs.aws.amazon.com/transcribe/latest/dg/API_GetTranscriptionJob.html + if (operation === 'get') { + const transcriptionJobName = this.getNodeParameter('transcriptionJobName', i) as string; + const resolve = this.getNodeParameter('returnTranscript', 0) as boolean; + + const body: IDataObject = { + TranscriptionJobName: transcriptionJobName, + }; + + const action = 'Transcribe.GetTranscriptionJob'; + responseData = await awsApiRequestREST.call(this, 'transcribe', 'POST', '', JSON.stringify(body), { 'x-amz-target': action, 'Content-Type': 'application/x-amz-json-1.1' }); + responseData = responseData.TranscriptionJob; + + if (resolve === true && responseData.TranscriptionJobStatus === 'COMPLETED') { + responseData = await this.helpers.request({ method: 'GET', uri: responseData.Transcript.TranscriptFileUri, json: true }); + const simple = this.getNodeParameter('simple', 0) as boolean; + if (simple === true) { + responseData = { transcript: responseData.results.transcripts.map((data: IDataObject) => data.transcript).join(' ') }; + } + } + } + //https://docs.aws.amazon.com/transcribe/latest/dg/API_ListTranscriptionJobs.html + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const filters = this.getNodeParameter('filters', i) as IDataObject; + const action = 'Transcribe.ListTranscriptionJobs'; + const body: IDataObject = {}; + + if (filters.status) { + body['Status'] = filters.status; + } + + if (filters.jobNameContains) { + body['JobNameContains'] = filters.jobNameContains; + } + + if (returnAll === true) { + responseData = await awsApiRequestRESTAllItems.call(this, 'TranscriptionJobSummaries', 'transcribe', 'POST', '', JSON.stringify(body), { 'x-amz-target': action, 'Content-Type': 'application/x-amz-json-1.1' }); + + } else { + const limit = this.getNodeParameter('limit', i) as number; + body['MaxResults'] = limit; + responseData = await awsApiRequestREST.call(this, 'transcribe', 'POST', '', JSON.stringify(body), { 'x-amz-target': action, 'Content-Type': 'application/x-amz-json-1.1' }); + responseData = responseData.TranscriptionJobSummaries; + } + } + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Aws/Transcribe/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/Transcribe/GenericFunctions.ts new file mode 100644 index 0000000000..177b349906 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Transcribe/GenericFunctions.ts @@ -0,0 +1,107 @@ +import { + URL, +} from 'url'; + +import { + sign, +} from 'aws4'; + +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + ICredentialDataDecryptedObject, + IDataObject, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +import { + get, +} from 'lodash'; + +function getEndpointForService(service: string, credentials: ICredentialDataDecryptedObject): string { + let endpoint; + if (service === 'lambda' && credentials.lambdaEndpoint) { + endpoint = credentials.lambdaEndpoint; + } else if (service === 'sns' && credentials.snsEndpoint) { + endpoint = credentials.snsEndpoint; + } else { + endpoint = `https://${service}.${credentials.region}.amazonaws.com`; + } + return (endpoint as string).replace('{region}', credentials.region as string); +} + +export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string, headers?: object): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('aws'); + if (credentials === undefined) { + throw new NodeOperationError(this.getNode(), 'No credentials got returned!'); + } + + // Concatenate path and instantiate URL object so it parses correctly query strings + const endpoint = new URL(getEndpointForService(service, credentials) + path); + + // Sign AWS API request with the user credentials + const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body }; + sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); + + + const options: OptionsWithUri = { + headers: signOpts.headers, + method, + uri: endpoint.href, + body: signOpts.body, + }; + + try { + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); // no XML parsing needed + } +} + +export async function awsApiRequestREST(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, service: string, method: string, path: string, body?: string, headers?: object): Promise { // tslint:disable-line:no-any + const response = await awsApiRequest.call(this, service, method, path, body, headers); + try { + return JSON.parse(response); + } catch (error) { + return response; + } +} + +export async function awsApiRequestRESTAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers: IDataObject = {}, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + const propertyNameArray = propertyName.split('.'); + + do { + responseData = await awsApiRequestREST.call(this, service, method, path, body, query); + + if (get(responseData, `${propertyNameArray[0]}.${propertyNameArray[1]}.NextToken`)) { + query['NextToken'] = get(responseData, `${propertyNameArray[0]}.${propertyNameArray[1]}.NextToken`); + } + if (get(responseData, propertyName)) { + if (Array.isArray(get(responseData, propertyName))) { + returnData.push.apply(returnData, get(responseData, propertyName)); + } else { + returnData.push(get(responseData, propertyName)); + } + } + } while ( + get(responseData, `${propertyNameArray[0]}.${propertyNameArray[1]}.NextToken`) !== undefined + ); + + return returnData; +} + diff --git a/packages/nodes-base/nodes/Aws/Transcribe/transcribe.svg b/packages/nodes-base/nodes/Aws/Transcribe/transcribe.svg new file mode 100644 index 0000000000..d055f5e9c7 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Transcribe/transcribe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9c1a149ce6..6a11aa1f59 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -300,6 +300,7 @@ "dist/nodes/Aws/S3/AwsS3.node.js", "dist/nodes/Aws/SES/AwsSes.node.js", "dist/nodes/Aws/SQS/AwsSqs.node.js", + "dist/nodes/Aws/Transcribe/AwsTranscribe.node.js", "dist/nodes/Aws/AwsSns.node.js", "dist/nodes/Aws/AwsSnsTrigger.node.js", "dist/nodes/Bannerbear/Bannerbear.node.js",