├── .gitignore ├── docs └── metadata.png ├── boilerplate-skills ├── single-server-model.png ├── multiple-server-model.png ├── basic-demo-self-deploy │ ├── package-lock.json │ ├── package.json │ ├── deploy.js │ └── index.js ├── serverless-demo-automated │ ├── .eslintrc │ ├── integration-test-request.json │ ├── package.json │ ├── serverless.yml │ └── index.js └── README.md ├── skills-kit-library ├── package.json ├── README.md ├── test │ └── skills-kit-2.0.test.js └── skills-kit-2.0.js ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | **/.serverless/ 2 | **/DS_Store 3 | **/node_modules/ 4 | **/coverage/ 5 | -------------------------------------------------------------------------------- /docs/metadata.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/box/box-skills-kit-nodejs/HEAD/docs/metadata.png -------------------------------------------------------------------------------- /boilerplate-skills/single-server-model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/box/box-skills-kit-nodejs/HEAD/boilerplate-skills/single-server-model.png -------------------------------------------------------------------------------- /boilerplate-skills/multiple-server-model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/box/box-skills-kit-nodejs/HEAD/boilerplate-skills/multiple-server-model.png -------------------------------------------------------------------------------- /boilerplate-skills/basic-demo-self-deploy/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic-demo-self-deploy", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /boilerplate-skills/serverless-demo-automated/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "airbnb-base", 5 | "prettier" 6 | ], 7 | "env": { 8 | "es6": true, 9 | "jest": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /boilerplate-skills/basic-demo-self-deploy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic-demo-self-deploy", 3 | "version": "1.0.0", 4 | "description": "Boilerplate Box Skill Code Using Skills-Kit", 5 | "author": "Box ", 6 | "license": "Apache-2.0", 7 | "homepage": "https://developer.box.com/docs/box-skills", 8 | "scripts": { 9 | "postinstall": "npm link ../../skills-kit-library;", 10 | "deploy": "node deploy.js" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /boilerplate-skills/serverless-demo-automated/integration-test-request.json: -------------------------------------------------------------------------------- 1 | ONCE INTEGRATED WITH BOX PASTE A SAMPLE INCOMING EVENT JSON HERE FOR FASTER TESTING TURAROUND DURING DEVELOPMENT 2 | IF YOU CHOOSE TO INVOKE A MOCK BOX CALL RIGHT AFTER SERVERLESS DEPLOYMENT 3 | (SO THAT YOU DON'T HAVE TO UPLOAD FILE TO YOUR SKILL ENABLED FOLDER TO TEST), 4 | ADD THE FOLLOWING LINE IN YOUR NPM SCRIPTS: 5 | 6 | "postdeploy": "./node_modules/.bin/serverless invoke --function skill --path integration-test-request.json --log" 7 | -------------------------------------------------------------------------------- /skills-kit-library/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skills-kit-library", 3 | "version": "2.0.1", 4 | "description": "Boilerplate Box Skill Code Using Skills-Kit, Serverless, Eslint, Jest", 5 | "author": "Box ", 6 | "license": "Apache-2.0", 7 | "homepage": "https://developer.box.com/docs/box-skills", 8 | "scripts": { 9 | "test": "jest --coverage" 10 | }, 11 | "dependencies": { 12 | "box-node-sdk": "^1.9.0", 13 | "jest": "^22.4.3", 14 | "jimp": "^0.5.4", 15 | "lodash": "^4.17.13", 16 | "path": "^0.12.7", 17 | "url-template": "^2.0.8" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git@github.com:box/box-skills-kit-nodejs.git" 22 | }, 23 | "jest": { 24 | "verbose": true, 25 | "testURL": "http://localhost", 26 | "coverageThreshold": { 27 | "global": { 28 | "branches": 65, 29 | "functions": 85, 30 | "lines": 85, 31 | "statements": 85 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /boilerplate-skills/serverless-demo-automated/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-demo-automated", 3 | "version": "1.0.0", 4 | "description": "Boilerplate Box Skill Code Using Skills-Kit-Lib, Serverless, Eslint, Jest", 5 | "scripts": { 6 | "format": "NODE_ENV=dev ./node_modules/.bin/prettier \"**/*.js\" --print-width 120 --single-quote --tab-width 4 --write; ./node_modules/.bin/eslint \"**/*.js\" --fix", 7 | "test": "NODE_ENV=dev ./node_modules/.bin/jest --coverage", 8 | "postinstall": "npm link ../../skills-kit-library", 9 | "deploy": "npm install; ./node_modules/.bin/serverless deploy", 10 | "undeploy": "NODE_ENV=dev ./node_modules/.bin/serverless remove -v" 11 | }, 12 | "author": "Box ", 13 | "license": "Apache-2.0", 14 | "repository": { 15 | "type": "git", 16 | "url": "git@github.com:box/box-skills-kit-nodejs.git" 17 | }, 18 | "homepage": "https://developer.box.com/docs/box-skills", 19 | "devDependencies": { 20 | "babel-eslint": "^8.2.2", 21 | "eslint": "^4.19.1", 22 | "eslint-config-airbnb-base": "^12.1.0", 23 | "eslint-config-prettier": "^2.9.0", 24 | "eslint-plugin-import": "^2.9.0", 25 | "jest": "^22.4.3", 26 | "prettier": "^1.11.1", 27 | "serverless": "^1.28.0" 28 | }, 29 | "jest": { 30 | "verbose": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /boilerplate-skills/serverless-demo-automated/serverless.yml: -------------------------------------------------------------------------------- 1 | # For full Serverless config options, check the docs: 2 | # https://serverless.com/framework/docs/providers/aws/guide/serverless.yml/ 3 | 4 | service: my-quick-automated-demo 5 | 6 | # You can change this to any cloud provider: 7 | # AWS Lambda: 8 | # Microsoft Azure: 9 | # Google Cloud Functions: 10 | # IBM Cloud Functions: 11 | # 12 | # before you deploy your app, you should set credential with the follow cmd: 13 | # serverless config credentials --provider aws --key $AWS_KEY$ --secret $AWS_SECRET$ -o 14 | provider: 15 | name: aws 16 | runtime: nodejs8.10 17 | timeout: 10 # server should still acknowledge Box skills engine within 10 seconds of receiving the event 18 | versionFunctions: true # maintains past versions in single deployment 19 | 20 | 21 | # overwriting defaults 22 | stage: ${opt:stage, 'dev'} 23 | region: us-west-2 24 | 25 | # packaging information 26 | package: 27 | include: 28 | - index.js 29 | - package.json 30 | - node_modules 31 | 32 | functions: 33 | skill: 34 | handler: index.handler 35 | events: # The Events that trigger this Function 36 | - http: 37 | path: ${self:service} 38 | method: any 39 | -------------------------------------------------------------------------------- /boilerplate-skills/basic-demo-self-deploy/deploy.js: -------------------------------------------------------------------------------- 1 | //Load HTTP module 2 | // NOTE: After running your server on a fixed public IP, you will need to install 3 | // an SSL certificate to register the URL with Box. For local HTTP development, you can 4 | // create Your Own SSL Certificate Authority and self-sign it, until you get it on a final 5 | // server machine/ 6 | 7 | const index = require('./index'); 8 | const http = require('http'); 9 | const util = require('util'); 10 | 11 | const hostname = '127.0.0.1'; // replace with your public IP 12 | const port = 443; // Box is only able to send events to an https endpoint, make sure you have generated 13 | // and added an SSL certificate, even if just for testing. 14 | 15 | //Create HTTP server and listen on port 443 for requests 16 | 17 | const server = http.createServer((req, res) => { 18 | console.log(`Box Skills Request recieved: ${util.inspect(req)}`); 19 | 20 | let body = ''; 21 | req.on('data', chunk => { 22 | body += chunk; 23 | }); 24 | req.on('end', () => { 25 | console.log(`body = ${body}`); 26 | 27 | index.handler(body).then((resolve, reject) => { 28 | // Set the response HTTP header with HTTP status and Content type 29 | res.statusCode = 200; 30 | res.setHeader('Content-Type', 'text/plain'); 31 | res.end('Skill Processed'); 32 | }); 33 | }); 34 | }); 35 | 36 | //listen for request on port 8000, and as a callback function have the port listened on logged 37 | server.listen(port, hostname, () => { 38 | console.log(`Server running at http://${hostname}:${port}/`); 39 | }); 40 | -------------------------------------------------------------------------------- /boilerplate-skills/basic-demo-self-deploy/index.js: -------------------------------------------------------------------------------- 1 | // Import FilesReader and SkillsWriter classes from skills-kit-2.0.js library 2 | const { FilesReader, SkillsWriter, SkillsErrorEnum } = require('skills-kit-library/skills-kit-2.0'); 3 | 4 | module.exports.handler = async (body, context, callback) => { 5 | // instantiate your two skill development helper tools 6 | const filesReader = new FilesReader(body); 7 | const skillsWriter = new SkillsWriter(filesReader.getFileContext()); 8 | 9 | try { 10 | // One of six ways of accessing file content from Box for ML processing with FilesReader 11 | // ML processing code not shown here, and will need to be added by the skill developer. 12 | const base64File = await filesReader.getContentBase64(); // eslint-disable-line no-unused-vars 13 | console.log(`printing simplified format file content in base64 encoding: ${base64File}`); 14 | 15 | const mockListOfDiscoveredKeywords = [{ text: 'file' }, { text: 'associated' }, { text: 'keywords' }]; 16 | const mockListOfDiscoveredTranscripts = [{ text: `This is a sentence/transcript card` }]; 17 | const mockListOfDiscoveredFaceWithPublicImageURI = [ 18 | { 19 | image_url: 'https://seeklogo.com/images/B/box-logo-646A3D8C91-seeklogo.com.png', 20 | text: `Image hover/placeholder text if image doesn't load` 21 | } 22 | ]; 23 | const mockListOfTranscriptsWithAppearsAtForPlaybackFiles = [ 24 | { 25 | text: 'Timeline data can be shown in any card type', 26 | appears: [{ start: 1, end: 2 }] 27 | }, 28 | { 29 | text: "Just add 'appears' field besides any 'text', with start and end values in seconds", 30 | appears: [{ start: 3, end: 4 }] 31 | } 32 | ]; 33 | 34 | // Turn your data into correctly formatted card jsons usking SkillsWriter. 35 | // The cards will appear in UI in same order as they are passed in a list. 36 | const cards = []; 37 | cards.push(await skillsWriter.createFacesCard(mockListOfDiscoveredFaceWithPublicImageURI, null, 'Icons')); // changing card title to non-default 'Icons'. 38 | cards.push(skillsWriter.createTopicsCard(mockListOfDiscoveredKeywords)); 39 | cards.push(skillsWriter.createTranscriptsCard(mockListOfDiscoveredTranscripts)); 40 | cards.push(skillsWriter.createTranscriptsCard(mockListOfTranscriptsWithAppearsAtForPlaybackFiles, 5)); // for timeline total playtime seconds of file also needs to be passed. 41 | 42 | // Save the cards to Box in a single calls to show in UI. 43 | // Incase the skill is invoked on a new version upload of the same file, 44 | // this call will override any existing skills cards, data or error, on Box file preview. 45 | console.log(`cardss ${cards}`); 46 | await skillsWriter.saveDataCards(cards); 47 | } catch (error) { 48 | // Incase of error, write back an error card to UI. 49 | // Note: Skill developers may want to inspect the 'error' variable 50 | // and write back more specific errorCodes (@print SkillsErrorEnum) 51 | console.error( 52 | `Skill processing failed for file: ${filesReader.getFileContext().fileId} with error: ${error.message}` 53 | ); 54 | await skillsWriter.saveErrorCard(SkillsErrorEnum.UNKNOWN); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /boilerplate-skills/serverless-demo-automated/index.js: -------------------------------------------------------------------------------- 1 | // Import FilesReader and SkillsWriter classes from skills-kit-2.0.js library 2 | const { FilesReader, SkillsWriter, SkillsErrorEnum } = require('skills-kit-library/skills-kit-2.0'); 3 | 4 | module.exports.handler = async (event, context, callback) => { 5 | // During code development you can copy an incoming box skills event 6 | // and paste it within integration-test-request.json file. This static request 7 | // would be invoked after every cloud deployment and can save you the time it takes 8 | // to repeatedely upload a file to box and trigger skill for testing. 9 | // Note: the static request event expires after some hours. 10 | console.debug(`Box event received: ${JSON.stringify(event)}`); 11 | 12 | // instantiate your two skill development helper tools 13 | const filesReader = new FilesReader(event.body); 14 | const skillsWriter = new SkillsWriter(filesReader.getFileContext()); 15 | 16 | await skillsWriter.saveProcessingCard(); 17 | 18 | try { 19 | // One of six ways of accessing file content from Box for ML processing with FilesReader 20 | // ML processing code not shown here, and will need to be added by the skill developer. 21 | const base64File = await filesReader.getContentBase64(); // eslint-disable-line no-unused-vars 22 | console.log(`printing simplified format file content in base64 encoding: ${base64File}`); 23 | 24 | const mockListOfDiscoveredKeywords = [{ text: 'file' }, { text: 'associated' }, { text: 'keywords' }]; 25 | const mockListOfDiscoveredTranscripts = [{ text: `This is a sentence/transcript card` }]; 26 | const mockListOfDiscoveredFaceWithPublicImageURI = [ 27 | { 28 | image_url: 'https://seeklogo.com/images/B/box-logo-646A3D8C91-seeklogo.com.png', 29 | text: `Image hover/placeholder text if image doesn't load` 30 | } 31 | ]; 32 | const mockListOfTranscriptsWithAppearsAtForPlaybackFiles = [ 33 | { 34 | text: 'Timeline data can be shown in any card type', 35 | appears: [{ start: 1, end: 2 }] 36 | }, 37 | { 38 | text: "Just add 'appears' field besides any 'text', with start and end values in seconds", 39 | appears: [{ start: 3, end: 4 }] 40 | } 41 | ]; 42 | 43 | // Turn your data into correctly formatted card jsons usking SkillsWriter. 44 | // The cards will appear in UI in same order as they are passed in a list. 45 | const cards = []; 46 | cards.push(await skillsWriter.createFacesCard(mockListOfDiscoveredFaceWithPublicImageURI, null, 'Icons')); // changing card title to non-default 'Icons'. 47 | cards.push(skillsWriter.createTopicsCard(mockListOfDiscoveredKeywords)); 48 | cards.push(skillsWriter.createTranscriptsCard(mockListOfDiscoveredTranscripts)); 49 | cards.push(skillsWriter.createTranscriptsCard(mockListOfTranscriptsWithAppearsAtForPlaybackFiles, 5)); // for timeline total playtime seconds of file also needs to be passed. 50 | 51 | // Save the cards to Box in a single calls to show in UI. 52 | // Incase the skill is invoked on a new version upload of the same file, 53 | // this call will override any existing skills cards, data or error, on Box file preview. 54 | console.log(`cards ${JSON.stringify(cards)}`); 55 | await skillsWriter.saveDataCards(cards); 56 | } catch (error) { 57 | // Incase of error, write back an error card to UI. 58 | // Note: Skill developers may want to inspect the 'error' variable 59 | // and write back more specific errorCodes (@print SkillsWriter.error.keys()) 60 | console.error( 61 | `Skill processing failed for file: ${filesReader.getFileContext().fileId} with error: ${error.message}` 62 | ); 63 | await skillsWriter.saveErrorCard(SkillsErrorEnum.UNKNOWN); 64 | } finally { 65 | // Skills engine requires a 200 response within 10 seconds of sending an event. 66 | // Please see different code architecture configurations in git docs, 67 | // that you can apply to make sure your service always responds within time. 68 | callback(null, { statusCode: 200, body: 'Box event was processed by skill' }); 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /boilerplate-skills/README.md: -------------------------------------------------------------------------------- 1 | ## Example Box Skill Code Using Only Skills-Kit-Lib 2 | 3 | If you prefer a self-managed deployment, you can setup your own server running a node application and copy and use the files from [basic-demo-self-deploy](basic-demo-self-deploy). With the endpoint created to received Skills event from Box, [configure it with Box](https://developer.box.com/docs/configure-a-box-skill). 4 | 5 | ## Example Box Skill Code using Skills-Kit-Lib, Serverless, Eslint & Jest 6 | 7 | The automated way of deploying and hosting your skills code is by putting it on a lambda cloud function with one of the commercial cloud service providers. Using a tool called serverless, we can do that in a quick and easy automated script deployment, as long as one has the user token for a cloud service provider account with permissions to create lambda cloud functions. This way one doesn't have to log into the cloud service provider's dashboard and update their code on the cloud function through manual upload and restarting of the service. 8 | 9 | * [Quick Deployment instructions](#quick-deployment-instructions) 10 | * [Using Eslint (formatting) and Jest (testing)](#using-eslint-formatting-and-jest-testing) 11 | 12 | ### Quick deployment instructions 13 | 14 | As pre-requisites install the following locally on your developement machine: 15 | 16 | * [Node](https://nodejs.org/dist/latest-v8.x/) (we recommend node-8 because then your development and deployment environment would match. Once you download and install node, you get access to the npm CLI or command line interface/terminal for all the commands later on) 17 | * [Serverless](https://serverless.com) ( hint: you may need to use sudo npm install ) 18 | 19 | Next generate your cloud provider keys to setup in serverless keys, through any provider of your choice: 20 | 21 | * AWS Lambda -> [console](https://aws.amazon.com/lambda/) | [instructions to create keys](https://serverless.com/framework/docs/providers/aws/guide/credentials#creating-aws-access-keys) 22 | * IBM Open Whisk -> [console](https://www.ibm.com/cloud/functions/details) | [instructions to create keys](https://serverless.com/framework/docs/providers/openwhisk/guide/credentials/) 23 | * Google Cloud Functions -> [console](https://cloud.google.com/functions/)| [instructions to create keys](https://serverless.com/framework/docs/providers/google/guide/credentials/) 24 | * Microsoft Azure -> [console](https://azure.microsoft.com/en-us/overview/serverless-computing/) | [instructions to create keys](https://serverless.com/framework/docs/providers/azure/guide/credentials/) 25 | * Others provider options -> [Kubeless, Spotinst, Auth0, Fn, etc](https://serverless.com/framework/docs/providers/) 26 | 27 | You can setup your local serverless to use your new keys with this [sample command](https://serverless.com/framework/docs/providers/aws/guide/credentials#setup-with-serverless-config-credentials-command) 28 | 29 | Next, copy this git project locally 30 | 31 | ``` 32 | git clone https://github.com/box/box-skills-kit-nodejs.git 33 | ``` 34 | 35 | or download and unzip: https://github.com/box/box-skills-kit-nodejs/archive/master.zip 36 | 37 | Finally, deploy serverless-demo-automated code using the Node CLI with **a single command**. Everytime you make change to the code, you need to rerun this command. 38 | 39 | ``` 40 | cd box-skills-kit-nodejs/boilerplate-skills/serverless-demo-automated 41 | npm run deploy 42 | ``` 43 | 44 | You should see an output such as this- 45 | 46 | ``` 47 | npm run deploy 48 | 49 | > serverless-demo-automated@1.0.0 deploy /your-project-path/box-skills-kit/boilerplate-skills/serverless-demo-automated 50 | > npm install; ./node_modules/.bin/serverless deploy 51 | 52 | 53 | > serverless-demo-automated@1.0.0 postinstall /your-project-path/box-skills-kit/boilerplate-skills/serverless-demo-automated 54 | > npm link ../../skills-kit-library 55 | 56 | up to date in 0.589s 57 | /your-home-path/.nvm/versions/node/v9.4.0/lib/node_modules/skills-kit-lib -> /your-project-path/box-skills-kit/skills-kit-library 58 | /your-project-path/box-skills-kit/boilerplate-skills/serverless-demo-automated/node_modules/skills-kit-library -> /your-home-path/.nvm/versions/node/v9.4.0/lib/node_modules/skills-kit-lib -> /your-project-path/box-skills-kit/skills-kit-library 59 | removed 1 package in 4.768s 60 | Serverless: Packaging service... 61 | Serverless: Excluding development dependencies... 62 | Serverless: Creating Stack... 63 | Serverless: Checking Stack create progress... 64 | ..... 65 | Serverless: Stack create finished... 66 | Serverless: Uploading CloudFormation file to S3... 67 | Serverless: Uploading artifacts... 68 | Serverless: Uploading service .zip file to S3 (13 MB)... 69 | Serverless: Validating template... 70 | Serverless: Updating Stack... 71 | Serverless: Checking Stack update progress... 72 | .............................. 73 | Serverless: Stack update finished... 74 | Service Information 75 | service: my-quick-automated-demo 76 | stage: dev 77 | region: us-west-2 78 | stack: my-quick-automated-demo-dev 79 | api keys: 80 | None 81 | endpoints: 82 | ANY - https://**********.execute-api.us-west-2.amazonaws.com/dev/my-quick-automated-demo 83 | functions: 84 | skill: my-quick-automated-demo-dev-skill 85 | ``` 86 | 87 | Use this endpoint `https://**********.execute-api.us-west-2.amazonaws.com/dev/my-quick-automated-demo` to [configure your skill with box](https://developer.box.com/docs/configure-a-box-skill). Everytime, a file is uploaded, moved or copied to one of the folder under the enterprise where your skill is enabled, your service would recieve an event, and you can see your code execution log, logging into your cloud portal. 88 | 89 | ### Using Eslint (formatting) and Jest (testing) 90 | 91 | You can improve the quality of your code by running formatting and unit-testing scripts. Simply type and enter- 92 | 93 | ``` 94 | npm run format 95 | ``` 96 | 97 | ``` 98 | npm run test 99 | ``` 100 | 101 | ## Extra! How to architect your skill services 102 | 103 | A lambda cloud function is a short-lived server instance that only exists when it recieves an event, and shut down when it the request has been processed. This can helpful in case you skill deployment follows the following architectures, since it doesn't use any more or less of the uptime than required to process your request. However, you can also be running your own server with multiple nodejs services. In either case you would need to look at individual case of how long the processing needs to run, if it's synchronous or asychronous, and architect your services accordingly by as either of these models. 104 | 105 | | multiple server architecture | 106 | single server architecture | 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Box Skills Kit for Node.js 2 | 3 | This is the official toolkit provided by [Box](https://box.com) for creating custom [Box Skills](https://developer.box.com/docs/box-skills) written in Node.js. 4 | 5 | * [What is a Box Skill?](#WhatisaBoxSkill) 6 | * [How does a Box Skill work?](#HowdoesaBoxSkillwork) 7 | * [What is this toolkit for?](#Whatisthistoolkitfor) 8 | * [Where can I learn more?](#WherecanIlearnmore) 9 | * [Installation](#Installation) 10 | * [Basic usage](#Basicusage) 11 | * [1. Reading the webhook payload](#Readingthewebhookpayload) 12 | * [2. Processing the file using a ML provider](#ProcessingthefileusingaMLprovider) 13 | * [3. Write Metadata to File](#WriteMetadatatoFile) 14 | * [(Optional) Deal with errors](#OptionalDealwitherrors) 15 | 16 | ## What is a Box Skill? 17 | 18 | A Box Skill is a type of application that performs custom processing for files uploaded to Box. 19 | 20 | Often, Box Skills leverage **third-party AI or machine learning providers** to automatically extract information from files upon upload to Box. For example, a Box Skill could automatically label objects in images using a computer vision service. 21 | 22 | The resulting data can then be written back to the file on Box as **rich metadata** cards. 23 | 24 | ![Metadata on a Video](docs/metadata.png) 25 | 26 | ## How does a Box Skill work? 27 | 28 | At a basic level, a Box Skill follows the following steps. 29 | 30 | 1. A file is uploaded to Box 31 | 1. Box triggers a webhook to your (serverless) endpoint 32 | 1. Your server (using the Box Skills kit) processes the webhook's event payload 33 | 1. Your server downloads the file, or passes a reference to the file to your machine learning provider 34 | 1. Your machine learning provider processes the file and provides some data to your server 35 | 1. Your server (using the Box Skills kit) writes rick metadata to the file on Box 36 | 1. Your users will now have new metadata available to them in the Box web app, including while searching for files. 37 | 38 | ## What is this toolkit for? 39 | 40 | This toolkit helps to simplify building a custom skill in Node.js. It simplifies processing the skill event from Box, accessing your file, and writing the metadata back. 41 | 42 | You will still need to hook things up to your own **Machine Learning provider**, including creating an account with them. This toolkit helps you with all the interactions with Box but does not help you with the API calls to your machine learning provider. 43 | 44 | ## Where can I learn more? 45 | 46 | For more information on Box Skills, what kind of metadata you can write back to Box, as well as visual instructions on configuring your skills code with Box, visit the [box skills developer documentation](https://developer.box.com/docs/box-skills). 47 | 48 | Additionally, have a look at: 49 | 50 | * More documentation on the [library's API](skills-kit-library) 51 | * A quick start on [deploying your first skills service](boilerplate-skills) 52 | * [More samples](https://github.com/box-community) of Box Custom Skills using various ML providers 53 | 54 | ## Installation 55 | 56 | As the Skills Kit is currently not available through NPM the easiest way to use the library is by downloading it and linking it in your project. 57 | 58 | ```sh 59 | # Clone the project 60 | git clone https://github.com/box/box-skills-kit-nodejs.git 61 | # Change into your own project 62 | cd your_project 63 | # Copy the skills kit library and the package.json 64 | # with its dependencies into your project 65 | cp -r ../box-skills-kit-nodejs/skills-kit-library . 66 | # Link the library into your project, and download its dependencies 67 | npm link ./skills-kit-library 68 | ``` 69 | 70 | Then, in your own code, you can include parts of the library as follows. 71 | 72 | ```js 73 | // For more examples of the modules available within the 74 | // library, see the rest of the documentation 75 | const { FilesReader } = require('./skills-kit-library/skills-kit-2.0.js') 76 | ``` 77 | 78 | ## Basic usage 79 | 80 | Writing your own Custom Box Skill will change depending on your data and the machine learning provider used. A generic example would look something like this. 81 | 82 | 83 | ### 1. Reading the webhook payload 84 | 85 | The way your request data (`event` in this example) comes in 86 | will differ depending on the web service you are using, 87 | though in our example we are assuming you are using the Serverless framework 88 | 89 | ```js 90 | // import the FilesReader from the kit 91 | const { FilesReader } = require('./skills-kit-library/skills-kit-2.0.js'); 92 | // Here, event is the webhook data received at your endpoint. 93 | const reader = new FilesReader(event.body); 94 | 95 | // the ID of the file 96 | const fileId = reader.getFileContext().fileId; 97 | // the read-only download URL of the file 98 | const fileURL = reader.getFileContext().fileDownloadURL; 99 | ``` 100 | 101 | ### 2. Processing the file using a ML provider 102 | 103 | This part will heavily depend on your machine learning provider. In this example we use a 104 | theoretical provider called `MLProvider`. 105 | 106 | ```js 107 | const { MLProvider } = require('your-ml-provider'); 108 | // import the SkillsWriter and SkillsErrorEnum from the kit 109 | const { SkillsWriter, SkillsErrorEnum } = require('./skills-kit-library/skills-kit-2.0.js'); 110 | 111 | // initialize the writer with the FilesReader instance, 112 | // informing the writer how to and where to write any metadata 113 | const writer = new SkillsWriter(reader.getFileContext()); 114 | 115 | // Write a "Processing"-card as metadata to the file on Box, 116 | // informing a user of the skill in process. 117 | await writer.saveProcessingCard(); 118 | 119 | // Finally, kick off your theoretical machine learning provider 120 | try { 121 | // (this code is pseudo code) 122 | const data = await new MLProvider(fileUrl).process() 123 | } catch { 124 | // Write an "Error"-card as metadata to your file if the processing failed 125 | await writer.saveErrorCard(SkillsErrorEnum.FILE_PROCESSING_ERROR); 126 | } 127 | ``` 128 | 129 | ### 3. Write Metadata to File 130 | 131 | Finally, once your machine learning provider has processed your file, you can write the data received from them as various forms of **metadata** to a file. 132 | 133 | ```js 134 | // In this case we assume your data is some kind of array of objects with keywords 135 | // e.g.: 136 | // [ 137 | // { keyword: 'Keyword 1' }, 138 | // { keyword: 'Keyword 2' }, 139 | // { keyword: 'Keyword 1' }, 140 | // ... 141 | // ] 142 | let entries = []; 143 | data.forEach(entry => { 144 | entries.push({ 145 | type: 'text', 146 | text: entry.keyword 147 | }) 148 | }); 149 | 150 | // Convert the entries into a Keyword Card 151 | const card = writer.createTopicsCard(entries); 152 | 153 | // Write the card as metadata to the file 154 | await writer.saveDataCards([card]); 155 | ``` 156 | 157 | ### (Optional) Deal with errors 158 | 159 | In any of these steps a failure, either when reading the file, processing the file, or writing the metadata to the file. 160 | 161 | The skills kit makes it simple to write powerful error messages to your file as metadata cards. 162 | 163 | ```js 164 | const { SkillsErrorEnum } = require('./skills-kit-library/skills-kit-2.0.js'); 165 | await writer.saveErrorCard(SkillsErrorEnum.FILE_PROCESSING_ERROR); 166 | ``` 167 | 168 | See the Skills Kit API documentation for a [full list of available errors](skills-kit-library#error-enum). 169 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | "License" shall mean the terms and conditions for use, reproduction, 9 | and distribution as defined by Sections 1 through 9 of this document. 10 | "Licensor" shall mean the copyright owner or entity authorized by 11 | the copyright owner that is granting the License. 12 | "Legal Entity" shall mean the union of the acting entity and all 13 | other entities that control, are controlled by, or are under common 14 | control with that entity. For the purposes of this definition, 15 | "control" means (i) the power, direct or indirect, to cause the 16 | direction or management of such entity, whether by contract or 17 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 18 | outstanding shares, or (iii) beneficial ownership of such entity. 19 | "You" (or "Your") shall mean an individual or Legal Entity 20 | exercising permissions granted by this License. 21 | "Source" form shall mean the preferred form for making modifications, 22 | including but not limited to software source code, documentation 23 | source, and configuration files. 24 | "Object" form shall mean any form resulting from mechanical 25 | transformation or translation of a Source form, including but 26 | not limited to compiled object code, generated documentation, 27 | and conversions to other media types. 28 | "Work" shall mean the work of authorship, whether in Source or 29 | Object form, made available under the License, as indicated by a 30 | copyright notice that is included in or attached to the work 31 | (an example is provided in the Appendix below). 32 | "Derivative Works" shall mean any work, whether in Source or Object 33 | form, that is based on (or derived from) the Work and for which the 34 | editorial revisions, annotations, elaborations, or other modifications 35 | represent, as a whole, an original work of authorship. For the purposes 36 | of this License, Derivative Works shall not include works that remain 37 | separable from, or merely link (or bind by name) to the interfaces of, 38 | the Work and Derivative Works thereof. 39 | "Contribution" shall mean any work of authorship, including 40 | the original version of the Work and any modifications or additions 41 | to that Work or Derivative Works thereof, that is intentionally 42 | submitted to Licensor for inclusion in the Work by the copyright owner 43 | or by an individual or Legal Entity authorized to submit on behalf of 44 | the copyright owner. For the purposes of this definition, "submitted" 45 | means any form of electronic, verbal, or written communication sent 46 | to the Licensor or its representatives, including but not limited to 47 | communication on electronic mailing lists, source code control systems, 48 | and issue tracking systems that are managed by, or on behalf of, the 49 | Licensor for the purpose of discussing and improving the Work, but 50 | excluding communication that is conspicuously marked or otherwise 51 | designated in writing by the copyright owner as "Not a Contribution." 52 | "Contributor" shall mean Licensor and any individual or Legal Entity 53 | on behalf of whom a Contribution has been received by Licensor and 54 | subsequently incorporated within the Work. 55 | 56 | 2. Grant of Copyright License. Subject to the terms and conditions of 57 | this License, each Contributor hereby grants to You a perpetual, 58 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 59 | copyright license to reproduce, prepare Derivative Works of, 60 | publicly display, publicly perform, sublicense, and distribute the 61 | Work and such Derivative Works in Source or Object form. 62 | 63 | 3. Grant of Patent License. Subject to the terms and conditions of 64 | this License, each Contributor hereby grants to You a perpetual, 65 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 66 | (except as stated in this section) patent license to make, have made, 67 | use, offer to sell, sell, import, and otherwise transfer the Work, 68 | where such license applies only to those patent claims licensable 69 | by such Contributor that are necessarily infringed by their 70 | Contribution(s) alone or by combination of their Contribution(s) 71 | with the Work to which such Contribution(s) was submitted. If You 72 | institute patent litigation against any entity (including a 73 | cross-claim or counterclaim in a lawsuit) alleging that the Work 74 | or a Contribution incorporated within the Work constitutes direct 75 | or contributory patent infringement, then any patent licenses 76 | granted to You under this License for that Work shall terminate 77 | as of the date such litigation is filed. 78 | 79 | 4. Redistribution. You may reproduce and distribute copies of the 80 | Work or Derivative Works thereof in any medium, with or without 81 | modifications, and in Source or Object form, provided that You 82 | meet the following conditions: 83 | 84 | (a) You must give any other recipients of the Work or 85 | Derivative Works a copy of this License; and 86 | 87 | (b) You must cause any modified files to carry prominent notices 88 | stating that You changed the files; and 89 | 90 | (c) You must retain, in the Source form of any Derivative Works 91 | that You distribute, all copyright, patent, trademark, and 92 | attribution notices from the Source form of the Work, 93 | excluding those notices that do not pertain to any part of 94 | the Derivative Works; and 95 | 96 | (d) If the Work includes a "NOTICE" text file as part of its 97 | distribution, then any Derivative Works that You distribute must 98 | include a readable copy of the attribution notices contained 99 | within such NOTICE file, excluding those notices that do not 100 | pertain to any part of the Derivative Works, in at least one 101 | of the following places: within a NOTICE text file distributed 102 | as part of the Derivative Works; within the Source form or 103 | documentation, if provided along with the Derivative Works; or, 104 | within a display generated by the Derivative Works, if and 105 | wherever such third-party notices normally appear. The contents 106 | of the NOTICE file are for informational purposes only and 107 | do not modify the License. You may add Your own attribution 108 | notices within Derivative Works that You distribute, alongside 109 | or as an addendum to the NOTICE text from the Work, provided 110 | that such additional attribution notices cannot be construed 111 | as modifying the License. 112 | 113 | You may add Your own copyright statement to Your modifications and 114 | may provide additional or different license terms and conditions 115 | for use, reproduction, or distribution of Your modifications, or 116 | for any such Derivative Works as a whole, provided Your use, 117 | reproduction, and distribution of the Work otherwise complies with 118 | the conditions stated in this License. 119 | 120 | 5. Submission of Contributions. Unless You explicitly state otherwise, 121 | any Contribution intentionally submitted for inclusion in the Work 122 | by You to the Licensor shall be under the terms and conditions of 123 | this License, without any additional terms or conditions. 124 | Notwithstanding the above, nothing herein shall supersede or modify 125 | the terms of any separate license agreement you may have executed 126 | with Licensor regarding such Contributions. 127 | 128 | 6. Trademarks. This License does not grant permission to use the trade 129 | names, trademarks, service marks, or product names of the Licensor, 130 | except as required for reasonable and customary use in describing the 131 | origin of the Work and reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. Unless required by applicable law or 134 | agreed to in writing, Licensor provides the Work (and each 135 | Contributor provides its Contributions) on an "AS IS" BASIS, 136 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 137 | implied, including, without limitation, any warranties or conditions 138 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 139 | PARTICULAR PURPOSE. You are solely responsible for determining the 140 | appropriateness of using or redistributing the Work and assume any 141 | risks associated with Your exercise of permissions under this License. 142 | 143 | 8. Limitation of Liability. In no event and under no legal theory, 144 | whether in tort (including negligence), contract, or otherwise, 145 | unless required by applicable law (such as deliberate and grossly 146 | negligent acts) or agreed to in writing, shall any Contributor be 147 | liable to You for damages, including any direct, indirect, special, 148 | incidental, or consequential damages of any character arising as a 149 | result of this License or out of the use or inability to use the 150 | Work (including but not limited to damages for loss of goodwill, 151 | work stoppage, computer failure or malfunction, or any and all 152 | other commercial damages or losses), even if such Contributor 153 | has been advised of the possibility of such damages. 154 | 155 | 9. Accepting Warranty or Additional Liability. While redistributing 156 | the Work or Derivative Works thereof, You may choose to offer, 157 | and charge a fee for, acceptance of support, warranty, indemnity, 158 | or other liability obligations and/or rights consistent with this 159 | License. However, in accepting such obligations, You may act only 160 | on Your own behalf and on Your sole responsibility, not on behalf 161 | of any other Contributor, and only if You agree to indemnify, 162 | defend, and hold each Contributor harmless for any liability 163 | incurred by, or claims asserted against, such Contributor by reason 164 | of your accepting any such warranty or additional liability. 165 | 166 | END OF TERMS AND CONDITIONS 167 | -------------------------------------------------------------------------------- /skills-kit-library/README.md: -------------------------------------------------------------------------------- 1 | # Box Skills Kit API 2 | 3 | The Box Skills Kit is a little library that it easier for your application to interact with Box Skills. It simplifies receiving and interpreting event notifications from Box, retrieving data from Box, and writing metadata to Box. 4 | 5 | [Overview](#overview) | [FilesReader](#FilesReader) | [SkillsWriter](#SkillsWriter) | [SkillsErrorEnum](#SkillsErrorEnum) | [FileContext](#FileContext) 6 | 7 | ## Overview 8 | 9 | The library contains the following key exports: 10 | 11 | * The `FilesReader` class makes it easier to interpret incoming events from Box and can retrieve files for processing 12 | * The `SkillsWriter` class makes it easier to write data to a set of metadata templates in Box 13 | * The `SkillsErrorEnum` enum, containing common error templates you can use to write error messages to Box using the "ErrorStatus" metadata card 14 | 15 | ## Reference 16 | 17 | ### `FilesReader` 18 | 19 | The `FilesReader` is a helpful class to capture file information from an incoming event notification (webhook) from Box. It allows you to easily retrieve the file's content from Box. 20 | 21 | #### Constructor 22 | 23 | `new FilesReader(eventBody)` 24 | 25 | Initializes a new `FilesReader` object with the body of the event received from Box. 26 | 27 | #### Instance Functions 28 | 29 | | Function | Returns | Description | 30 | |------------------------------------------|------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 31 | | `getFileContext()` | [FileContext](#FileContext) object | Returns a `FileContext` object containing the attributes of the file | 32 | | `validateFormat(allowedFileFormatsList)` | Boolean | Checks if a given file is eligible to be processed by the skill as per the list of configured allowed formats for this skill | 33 | | `validateSize(allowedMegabytesNum)` | Boolean | Checks if a given file is eligible to be processed by the skill as per the size limit | 34 | | `async getContentBase64()` | String | Returns a blob of the file content in Base64 encoding. Please note that Machine Learning providers generally have a size limit on passing raw file content as a string | 35 | | `getContentStream()` | Stream | Returns a Stream that can be used to read the file directly from Box, or sent directly to a provider that suppports streams | 36 | | `async getBasicFormatFileURL()` | String | Similar to using `filesReader.getFileContext().fileDownloadURL` yet returns a URL to a file in a [Basic Format](#basic-format) file | 37 | | `async getBasicFormatContentBase64()` | String | Similar to using `filesReader.getContentBase64()` yet returns the file content in the [Basic Format](#basic-format) | 38 | | `async getBasicFormatContentStream()` | Stream | Similar to using `filesReader.getContentStream()` yet return a stream for the the [Basic Format](#basic-format) | 39 | 40 | ##### Basic Format 41 | 42 | A `BasicFormat` is an alternative, simplified representation for a file, allowing you to send more predictable and more acceptable information to your machine learning provider. 43 | 44 | Box converts your files for you automatically into these formats: 45 | 46 | | Original Format | Basic Format | 47 | |----------------------------|----------------| 48 | | Audio Files | MP3 | 49 | | Video Files | MP4 | 50 | | Images Files | JPG | 51 | | Documents and Images Files | Extracted Text | 52 | 53 | Caution should be excercised using `BasicFormat` for large files as it involves a time delay when reading the content. Your code may experience timeouts before the converted format is fetched. 54 | 55 | ### `SkillsWriter` 56 | 57 | The `SkillsWriter` is a helpful class used for writing metadata to Box. Writing metadata allows you to display cards within a file's metadata pane. We currently support cards for Topics, Transcripts, Timelines, Errors and Statuses. 58 | 59 | 60 | #### Constructor 61 | 62 | `new SkillsWriter(fileContext)` 63 | 64 | Initializes a new `Constructor` object with the [FileContext](#FileContext) received from `filesReader.getFileContext()`. 65 | 66 | #### Instance Functions 67 | 68 | 69 | | Function | Returns | Description | 70 | |-------------------------------------------------------------------------------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 71 | | `createTopicsCard(topicsDataList, [fileDuration, cardTitle])` | [DataCard](#DataCard) object | Creates a card used to show multiple keywords that are relevant to the topic of the file. `topicsDataList` is of form `[{ text: 'text1'}, { text: 'text2'} ]`. In case of an audio/video file you can also optionally show a timeline of where that words in the file, by passing down `topicsDataList` in the form `[{ text: 'text1', appears: [{ start: 0.0, end: 1.0 }]}, { text: 'text2', appears: [{ start: 1.0, end: 2.0 }]} ]` and by providing the `fileDuration` of the entire file. The default title of the card is "Topics" unless `cardTitle` is provided. | 72 | | `createTranscriptsCard(transcriptsDataList, [fileDuration, cardTitle])` | [DataCard](#DataCard) object | Creates a card used to show sentences such as speaker transcripts in audio/video, or OCR in images/documents. `transcriptDataList` is of form `[{ text: 'sentence1'}, { text: 'sentence2'} ]`. In case of an audio/video file you can also optionally show a timeline of where that sentence is spoken in the file, by passing down `transcriptsDataList` in the form `[{ text: 'sentence1', appears: [{ start: 0.0, end: 1.0 }]}, { text: 'sentence2', appears: [{ start: 1.0, end: 2.0 }]} ]` The default title of the card is "Transcript' unless `cardTitle` is provided. | 73 | | `async createFacesCard(facesDataList, [fileDuration, cardTitle])` | [DataCard](#DataCard) object | Creates a card used to show thumbnails of recognized faces or objects in the file, along with associated texts or sentences. facesDataList is of form `[{ text: 'text1', 'image_url' : thumbnailUri1}, { text: 'text2', 'image_url' : thumbnailUri2} ]`. In case of an audio/video file you can also optionally show a timeline of where that face appears in the file, by passing down `transcriptsDataList` in the form `[{ text: 'text1', appears: [{ start: 0.0, end: 1.0 }]}, { text: 'text2', appears: [{ start: 1.0, end: 2.0 }]} ]` and by providing the `fileDuration` of the entire file. The default title of the card is "Faces" unless `cardTitle` is provided. | 74 | | `async saveDataCards(listofDataCards, [callback])` | `null` | Creates multiple metadata cards at once. Please refer to `createTopicsCard()`, `createTranscriptsCard()` and `createFacesCard()` for details. Will override any existing pending or error status cards in the UI for that file version. | 75 | | `async saveProcessingCard([callback])` | `null` | Shows a UI card with the message "We're preparing to process your file. Please hold on!". This is used for temporarily informing your users know that your skill is processing the file. An optional `callback` function can be passed that will be notified once the card has been saved. | 76 | | `async saveErrorCard(error, [message, callback])` | `null` | Shows a UI card with the error message as noted in the [SkillsErrorEnum](#SkillsErrorEnum) table. This is used to notify a user of any kind of failure that occurs while running your Skill. An optional `message` will overwrite the the default message. An optional `callback` function can be passed that will be notified once the card has been saved. | 77 | 78 | 79 | ### `SkillsErrorEnum` 80 | 81 | The `SkillsErrorEnum` enum contains common error templates that you can use to write error messages to Box using the `ErrorStatus` metadata card. 82 | 83 | | Value | Message shown in Box UI | 84 | |-------------------------|---------------------------------------------------------------------------------------------| 85 | | `FILE_PROCESSING_ERROR` | "We're sorry, something went wrong with processing the file." | 86 | | `INVALID_FILE_SIZE` | "Something went wrong with processing the file. This file size is currently not supported." | 87 | | `INVALID_FILE_FORMAT` | "Something went wrong with processing the file. Invalid information received." | 88 | | `INVALID_EVENT` | "Something went wrong with processing the file. Invalid information received." | 89 | | `NO_INFO_FOUND` | "We're sorry, no skills information was found." | 90 | | `INVOCATIONS_ERROR` | "Something went wrong with running this skill or fetching its data." | 91 | | `EXTERNAL_AUTH_ERROR` | "Something went wrong with running this skill or fetching its data." | 92 | | `BILLING_ERROR` | "Something went wrong with running this skill or fetching its data." | 93 | | `UNKNOWN` | "Something went wrong with running this skill or fetching its data." | 94 | 95 | 96 | #### Example usage 97 | 98 | To write an error message, use the `SkillsWriter` for your file with the preferred error value. 99 | 100 | ```js 101 | skillsWriter.saveErrorCard(SkillsErrorEnum.FILE_PROCESSING_ERROR); 102 | ``` 103 | 104 | #### `FileContext` 105 | 106 | A set of attributes for the file to be processed. 107 | 108 | | Attribute | Description | 109 | |-------------------|------------------------------------------| 110 | | `fileId` | The file ID | 111 | | `fileName` | The file name | 112 | | `fileFormat` | The file format | 113 | | `fileType` | The type of file | 114 | | `fileSize` | The file size | 115 | | `fileDownloadURL` | The URL for downloading the file | 116 | | `fileReadToken` | The read-ony access token for the file | 117 | | `fileWriteToken` | The read/write access token for the file | 118 | | `skillId` | The skill ID that triggered this event | 119 | | `requestId` | The ID of this event | -------------------------------------------------------------------------------- /skills-kit-library/test/skills-kit-2.0.test.js: -------------------------------------------------------------------------------- 1 | const { FilesReader, SkillsWriter, SkillsErrorEnum } = require('./../skills-kit-2.0'); 2 | 3 | const { Readable } = require('stream'); 4 | const jimp = require('jimp'); 5 | const urlPath = require('box-node-sdk/lib/util/url-path'); 6 | 7 | const fileId = 34426356747; 8 | const fileName = 'sampleFileName.mp3'; 9 | const fileSize = 4200656; 10 | const boxRequestId = '50067dc9-b656-4712-a8fa-7b3dd53848cc_1591214556'; 11 | const skillId = 75; 12 | const readToken = 'readtoken12345'; 13 | const writeToken = 'writetoken12345'; 14 | 15 | const eventBody = { 16 | id: boxRequestId, 17 | skill: { 18 | id: skillId 19 | }, 20 | source: { 21 | id: fileId, 22 | name: fileName, 23 | size: fileSize 24 | }, 25 | token: { 26 | read: { 27 | access_token: readToken 28 | }, 29 | write: { 30 | access_token: writeToken 31 | } 32 | } 33 | }; 34 | 35 | const fileContext = { 36 | requestId: boxRequestId, 37 | skillId, 38 | fileId, 39 | fileWriteToken: writeToken 40 | }; 41 | 42 | // Mock file stream 43 | let mockFileStream; 44 | 45 | describe('SkillsErrorEnum', () => { 46 | test('skills errors', () => { 47 | expect(SkillsErrorEnum.FILE_PROCESSING_ERROR).toEqual('skills_file_processing_error'); 48 | expect(SkillsErrorEnum.INVALID_FILE_SIZE).toEqual('skills_invalid_file_size_error'); 49 | expect(SkillsErrorEnum.INVALID_FILE_FORMAT).toEqual('skills_invalid_file_format_error'); 50 | expect(SkillsErrorEnum.INVALID_EVENT).toEqual('skills_invalid_event_error'); 51 | expect(SkillsErrorEnum.NO_INFO_FOUND).toEqual('skills_no_info_found'); 52 | expect(SkillsErrorEnum.INVOCATIONS_ERROR).toEqual('skills_invocations_error'); 53 | expect(SkillsErrorEnum.EXTERNAL_AUTH_ERROR).toEqual('skills_external_auth_error'); 54 | expect(SkillsErrorEnum.BILLING_ERROR).toEqual('skills_billing_error'); 55 | expect(SkillsErrorEnum.UNKNOWN).toEqual('skills_unknown_error'); 56 | }); 57 | }); 58 | 59 | describe('FilesReader', () => { 60 | const body = JSON.stringify(eventBody); 61 | const reader = new FilesReader(body); 62 | 63 | beforeEach(() => { 64 | mockFileStream = new Readable(); 65 | mockFileStream.push('audiofilestream'); 66 | mockFileStream.push(null); 67 | }); 68 | 69 | test('validateSize() should return true for valid file size', () => { 70 | expect(reader.validateSize(5)).toEqual(true); 71 | }); 72 | 73 | test('validateSize() should throw exception for invalid file size', () => { 74 | console.error = jest.fn(); 75 | expect(() => { 76 | reader.validateSize(4); 77 | }).toThrowError(SkillsErrorEnum.INVALID_FILE_SIZE); 78 | expect(console.error).toBeCalled(); 79 | }); 80 | 81 | test('validateFormat() should return true for valid file format', () => { 82 | const allowedFileFormatsList = 'flac,mp3,wav'; 83 | expect(reader.validateFormat(allowedFileFormatsList)).toEqual(true); 84 | }); 85 | 86 | test('validateFormat() should throw exception for invalid file format', () => { 87 | const allowedFileFormatsList = 'jpeg,jpg,png,gif,bmp,tiff'; 88 | console.error = jest.fn(); 89 | expect(() => { 90 | reader.validateFormat(allowedFileFormatsList); 91 | }).toThrowError(SkillsErrorEnum.INVALID_FILE_FORMAT); 92 | expect(console.error).toBeCalled(); 93 | }); 94 | 95 | test('getFileContext() should return correct context object', () => { 96 | const expectedContext = { 97 | requestId: boxRequestId, 98 | skillId, 99 | fileId, 100 | fileName, 101 | fileSize, 102 | fileFormat: 'mp3', 103 | fileType: 'AUDIO', 104 | fileDownloadURL: `https://api.box.com/2.0/files/${fileId}/content?access_token=${readToken}`, 105 | fileReadToken: readToken, 106 | fileWriteToken: writeToken 107 | }; 108 | expect(reader.getFileContext()).toEqual(expectedContext); 109 | }); 110 | 111 | test('getContentStream() should return content stream', (done) => { 112 | reader.fileReadClient = { 113 | files: { 114 | getReadStream: jest.fn().mockReturnValue(Promise.resolve(mockFileStream)) 115 | } 116 | }; 117 | 118 | reader.getContentStream().then((stream) => { 119 | expect(stream).toEqual(mockFileStream); 120 | expect(reader.fileReadClient.files.getReadStream).toHaveBeenCalledWith(fileId, null, expect.anything()); 121 | }); 122 | done(); 123 | }); 124 | 125 | test('getContentBase64() should return base64 encoded content', () => { 126 | reader.getContentStream = jest.fn().mockReturnValue(Promise.resolve(mockFileStream)); 127 | 128 | return reader.getContentBase64().then((data) => { 129 | expect(data).toEqual('YXVkaW9maWxlc3RyZWFt'); 130 | expect(reader.getContentStream).toHaveBeenCalled(); 131 | }); 132 | }); 133 | 134 | test('getContentBase64() should throw exception for invalid image stream', () => { 135 | const invalidFileStream = 'filestream'; 136 | reader.getContentStream = jest.fn().mockReturnValue(Promise.resolve(invalidFileStream)); 137 | 138 | return reader.getContentBase64().catch((e) => { 139 | expect(e.message).toEqual('Invalid Stream, must be a readable stream.'); 140 | }); 141 | }); 142 | 143 | test('getBasicFormatContentStream() should return representation content', () => { 144 | reader.fileReadClient = { 145 | files: { 146 | getRepresentationContent: jest.fn().mockReturnValue(Promise.resolve()) 147 | } 148 | }; 149 | return reader.getBasicFormatContentStream().then(() => { 150 | expect(reader.fileReadClient.files.getRepresentationContent).toHaveBeenCalledWith(fileId, '[mp3]'); 151 | }); 152 | }); 153 | 154 | test('getBasicFormatContentStream() should throw exception for invalid clent', () => { 155 | reader.fileReadClient = { 156 | files: { 157 | getRepresentationContent: jest.fn().mockReturnValue( 158 | Promise.reject({ 159 | statusCode: 401 160 | }) 161 | ) 162 | } 163 | }; 164 | 165 | return reader.getBasicFormatContentStream().catch((e) => { 166 | expect(e.message).toEqual( 167 | 'The client provided is unauthorized. Client should have read access to the file passed' 168 | ); 169 | }); 170 | }); 171 | 172 | test('getBasicFormatContentStream() should throw exception for non 401 error', () => { 173 | reader.fileReadClient = { 174 | files: { 175 | getRepresentationContent: jest.fn().mockReturnValue(Promise.reject('error')) 176 | } 177 | }; 178 | 179 | return reader.getBasicFormatContentStream().catch((e) => { 180 | expect(e).toEqual('error'); 181 | }); 182 | }); 183 | 184 | test('getBasicFormatContentBase64() should return base64 encoded content in basic format', () => { 185 | reader.getBasicFormatContentStream = jest.fn().mockReturnValue(Promise.resolve(mockFileStream)); 186 | 187 | return reader.getBasicFormatContentBase64().then((data) => { 188 | expect(data).toEqual('YXVkaW9maWxlc3RyZWFt'); 189 | expect(reader.getBasicFormatContentStream).toHaveBeenCalled(); 190 | }); 191 | }); 192 | 193 | test('getBasicFormatFileURL() should throw exception if requested representation not found', () => { 194 | const missingReps = { entries: [] }; 195 | console.error = jest.fn(); 196 | 197 | reader.fileReadClient = { 198 | files: { 199 | getRepresentationInfo: jest.fn().mockReturnValue(Promise.resolve(missingReps)) 200 | } 201 | }; 202 | 203 | return reader.getBasicFormatFileURL().catch((error) => { 204 | expect(error.message).toEqual(SkillsErrorEnum.FILE_PROCESSING_ERROR); 205 | expect(console.error).toBeCalledWith('Could not get information for requested representation'); 206 | }); 207 | }); 208 | 209 | test('getBasicFormatFileURL() should return url template when rep status is success', () => { 210 | const repInfo = { 211 | status: { 212 | state: 'success' 213 | }, 214 | content: { 215 | url_template: 'url_template' 216 | } 217 | }; 218 | const reps = { 219 | entries: [repInfo] 220 | }; 221 | console.error = jest.fn(); 222 | 223 | reader.fileReadClient = { 224 | files: { 225 | getRepresentationInfo: jest.fn().mockReturnValue(Promise.resolve(reps)) 226 | } 227 | }; 228 | 229 | return reader.getBasicFormatFileURL().then((data) => { 230 | expect(data).toEqual('url_template?access_token=readtoken12345'); 231 | }); 232 | }); 233 | 234 | test('getBasicFormatFileURL() should return url template after polling successfully if rep status is pending', () => { 235 | const repInfo = { 236 | status: { state: 'pending' }, 237 | content: { url_template: 'url_template' }, 238 | info: { url: 'https://www.box.com' } 239 | }; 240 | const reps = { entries: [repInfo] }; 241 | const fileGetResponse = { 242 | statusCode: 200, 243 | body: { 244 | status: { 245 | state: 'success' 246 | }, 247 | content: { 248 | url_template: 'polled_url_template' 249 | } 250 | } 251 | }; 252 | 253 | reader.fileReadClient = { 254 | files: { 255 | getRepresentationInfo: jest.fn().mockReturnValue(Promise.resolve(reps)) 256 | }, 257 | get: jest.fn().mockReturnValue(Promise.resolve(fileGetResponse)) 258 | }; 259 | 260 | return reader.getBasicFormatFileURL().then((data) => { 261 | expect(data).toEqual('polled_url_template?access_token=readtoken12345'); 262 | }); 263 | }); 264 | 265 | test('getBasicFormatFileURL() should throw exception for unknown representation status', () => { 266 | const repInfo = { status: { state: 'error' } }; 267 | const reps = { entries: [repInfo] }; 268 | console.error = jest.fn(); 269 | 270 | reader.fileReadClient = { 271 | files: { 272 | getRepresentationInfo: jest.fn().mockReturnValue(Promise.resolve(reps)) 273 | } 274 | }; 275 | 276 | return reader.getBasicFormatFileURL().catch((error) => { 277 | expect(error.message).toEqual(SkillsErrorEnum.FILE_PROCESSING_ERROR); 278 | expect(console.error).toHaveBeenCalledWith('Representation had error status'); 279 | }); 280 | }); 281 | }); 282 | 283 | describe('SkillsWriter', () => { 284 | let writer; 285 | const mockDate = new Date().toISOString(); 286 | const dt = { 287 | toISOString: jest.fn().mockReturnValue(mockDate) 288 | }; 289 | global.Date = jest.fn(() => dt); 290 | 291 | beforeEach(() => { 292 | writer = new SkillsWriter(fileContext); 293 | }); 294 | 295 | test('createMetadataCard() should return metadata card', () => { 296 | const status = { 297 | code: 'skill_invoked_status', 298 | message: 'invoked' 299 | }; 300 | const entries = [{ text: 'test1', type: 'text' }, { text: 'test2', type: 'text' }]; 301 | const duration = 100; 302 | const expectedMetadataCard = { 303 | created_at: mockDate, 304 | type: 'skill_card', 305 | skill: { 306 | type: 'service', 307 | id: skillId 308 | }, 309 | skill_card_type: 'keyword', 310 | skill_card_title: { 311 | code: 'skills_topics', 312 | message: 'Topics' 313 | }, 314 | invocation: { 315 | type: 'skill_invocation', 316 | id: boxRequestId 317 | }, 318 | status, 319 | entries, 320 | duration 321 | }; 322 | const metadataCard = writer.createMetadataCard('keyword', 'Topics', status, entries, duration); 323 | 324 | expect(metadataCard).toEqual(expectedMetadataCard); 325 | }); 326 | 327 | test('createTopicsCard() should return topic card', () => { 328 | const topicDataList = [{ text: 'test1', type: 'text' }, { text: 'test2', type: 'text' }]; 329 | const duration = 222; 330 | const title = 'Test Title'; 331 | console.warn = jest.fn(); 332 | 333 | 334 | const expectedTopicCard = { 335 | created_at: mockDate, 336 | type: 'skill_card', 337 | skill: { 338 | type: 'service', 339 | id: skillId 340 | }, 341 | skill_card_type: 'keyword', 342 | skill_card_title: { 343 | code: 'skills_test_title', 344 | message: 'Test Title' 345 | }, 346 | invocation: { 347 | type: 'skill_invocation', 348 | id: boxRequestId 349 | }, 350 | entries: topicDataList, 351 | status: {}, 352 | duration 353 | }; 354 | 355 | const topicCard = writer.createTopicsCard(topicDataList, duration, title); 356 | expect(topicCard).toEqual(expectedTopicCard); 357 | }); 358 | 359 | test('createTranscriptsCard() should return transcript card', () => { 360 | const transcriptsDataList = [ 361 | { text: 'line1', appears: [{ start: 0, end: 10 }] }, 362 | { text: 'line2', appears: [{ start: 11, end: 20 }] } 363 | ]; 364 | const duration = 657; 365 | 366 | const expectedTranscriptCard = { 367 | created_at: mockDate, 368 | type: 'skill_card', 369 | skill: { 370 | type: 'service', 371 | id: skillId 372 | }, 373 | skill_card_type: 'transcript', 374 | skill_card_title: { 375 | code: 'skills_transcript', 376 | message: 'Transcript' 377 | }, 378 | invocation: { 379 | type: 'skill_invocation', 380 | id: boxRequestId 381 | }, 382 | entries: transcriptsDataList, 383 | status: {}, 384 | duration 385 | }; 386 | 387 | const transcriptCard = writer.createTranscriptsCard(transcriptsDataList, duration); 388 | expect(transcriptCard).toEqual(expectedTranscriptCard); 389 | }); 390 | 391 | test('createFacesCard() should return faces card', () => { 392 | jimp.read = jest.fn(expect.anything()).mockReturnValue(Promise.reject()); 393 | const facesDataList = [ 394 | { 395 | text: 'Unknown #1', 396 | appears: [{ start: 0, end: 10 }], 397 | image_url: 'https://box.com' 398 | }, 399 | { 400 | text: 'Unknown #2', 401 | appears: [{ start: 11, end: 20 }], 402 | image_url: 'https://developer.box.com/' 403 | } 404 | ]; 405 | 406 | const expectedFacesCard = { 407 | created_at: mockDate, 408 | type: 'skill_card', 409 | skill: { 410 | type: 'service', 411 | id: skillId 412 | }, 413 | skill_card_type: 'timeline', 414 | skill_card_title: { 415 | code: 'skills_faces', 416 | message: 'Faces' 417 | }, 418 | invocation: { 419 | type: 'skill_invocation', 420 | id: boxRequestId 421 | }, 422 | entries: facesDataList, 423 | status: {} 424 | }; 425 | 426 | return writer.createFacesCard(facesDataList).then((facesCard) => { 427 | expect(facesCard).toEqual(expectedFacesCard); 428 | }); 429 | }); 430 | 431 | test('saveProcessingCard() should save processing card', () => { 432 | const status = { 433 | code: 'skills_pending_status', 434 | message: 'We\'re preparing to process your file. Please hold on!' 435 | }; 436 | const statusCard = { status: 'card' }; 437 | const callback = () => {}; 438 | 439 | writer.createMetadataCard = jest.fn().mockReturnValue(statusCard); 440 | writer.saveDataCards = jest.fn(); 441 | 442 | writer.saveProcessingCard(callback); 443 | 444 | expect(writer.createMetadataCard).toHaveBeenCalledWith('status', 'Status', status); 445 | expect(writer.saveDataCards).toHaveBeenCalledWith([statusCard], callback, 'processing'); 446 | }); 447 | 448 | test('saveErrorCard() should save error card with one of the default codes from error enum', () => { 449 | const error = { code: SkillsErrorEnum.FILE_PROCESSING_ERROR }; 450 | const errorCard = { error: 'card' }; 451 | const callback = () => {}; 452 | 453 | writer.createMetadataCard = jest.fn().mockReturnValue(errorCard); 454 | writer.saveDataCards = jest.fn(); 455 | 456 | writer.saveErrorCard(SkillsErrorEnum.FILE_PROCESSING_ERROR, undefined, callback); 457 | 458 | expect(writer.createMetadataCard).toHaveBeenCalledWith('status', 'Error', error); 459 | expect(writer.saveDataCards).toHaveBeenCalledWith([errorCard], callback, 'permanent_failure'); 460 | }); 461 | 462 | test('saveErrorCard() should save error card with custom error message', () => { 463 | const customError = 'custom error message'; 464 | const error = { code: 'custom_error', message: customError }; 465 | const errorCard = { error: 'card' }; 466 | const callback = () => {}; 467 | 468 | writer.createMetadataCard = jest.fn().mockReturnValue(errorCard); 469 | writer.saveDataCards = jest.fn(); 470 | 471 | writer.saveErrorCard(SkillsErrorEnum.FILE_PROCESSING_ERROR, customError, callback); 472 | 473 | expect(writer.createMetadataCard).toHaveBeenCalledWith('status', 'Error', error); 474 | expect(writer.saveDataCards).toHaveBeenCalledWith([errorCard], callback, 'permanent_failure'); 475 | }); 476 | 477 | test('saveDataCards() should save data cards', () => { 478 | const status = 'success'; 479 | const usage = { unit: 'files', value: 1 }; 480 | const callback = () => {}; 481 | const dataCardsJson = JSON.stringify({ data: 'cards' }); 482 | const body = { 483 | status, 484 | file: { 485 | type: 'file', 486 | id: writer.fileId 487 | }, 488 | metadata: { 489 | cards: dataCardsJson 490 | }, 491 | usage 492 | }; 493 | 494 | const params = { 495 | body, 496 | headers: { 497 | 'Cache-Control': 'no-cache', 498 | 'Content-Type': 'application/json' 499 | } 500 | }; 501 | 502 | const apiPath = urlPath('/skill_invocations', skillId); 503 | 504 | const putFunc = jest.fn(); 505 | writer.fileWriteClient.wrapWithDefaultHandler = jest.fn().mockReturnValue(putFunc); 506 | 507 | writer.saveDataCards(dataCardsJson, callback); 508 | 509 | expect(writer.fileWriteClient.wrapWithDefaultHandler).toHaveBeenCalledWith(writer.fileWriteClient.put); 510 | expect(putFunc).toHaveBeenCalledWith(apiPath, params, callback); 511 | }); 512 | }); 513 | -------------------------------------------------------------------------------- /skills-kit-library/skills-kit-2.0.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Box Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Unless required by applicable law or agreed to in writing, software 9 | * distributed under the License is distributed on an "AS IS" BASIS, 10 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | * See the License for the specific language governing permissions and 12 | * limitations under the License. 13 | */ 14 | 15 | /* External modules */ 16 | const BoxSDK = require('box-node-sdk'); 17 | const urlPath = require('box-node-sdk/lib/util/url-path'); 18 | const path = require('path'); 19 | const trimStart = require('lodash/trimStart'); 20 | const jimp = require('jimp'); 21 | const urlTemplate = require('url-template'); 22 | 23 | /* Constant values for writing cards to skill_invocations service */ 24 | const BASE_PATH = '/skill_invocations'; // Base path for all files endpoints 25 | const SKILLS_SERVICE_TYPE = 'service'; 26 | const SKILLS_METADATA_CARD_TYPE = 'skill_card'; 27 | const SKILLS_METADATA_INVOCATION_TYPE = 'skill_invocation'; 28 | 29 | const sdk = new BoxSDK({ 30 | clientID: 'BoxSkillsClientId', 31 | clientSecret: 'BoxSkillsClientSecret' 32 | }); 33 | 34 | const BOX_API_ENDPOINT = 'https://api.box.com/2.0'; 35 | const MB_INTO_BYTES = 1048576; 36 | const FileType = { 37 | AUDIO: { name: 'AUDIO', representationType: '[mp3]' }, 38 | VIDEO: { name: 'VIDEO', representationType: '[mp4]' }, 39 | IMAGE: { name: 'IMAGE', representationType: '[jpg?dimensions=1024x1024]' }, 40 | DOCUMENT: { name: 'DOCUMENT', representationType: '[extracted_text]' } 41 | }; 42 | 43 | const boxVideoFormats = [ 44 | '3g2', 45 | '3gp', 46 | 'avi', 47 | 'flv', 48 | 'm2v', 49 | 'm2ts', 50 | 'm4v', 51 | 'mkv', 52 | 'mov', 53 | 'mp4', 54 | 'mpeg', 55 | 'mpg', 56 | 'ogg', 57 | 'mts', 58 | 'qt', 59 | 'ts', 60 | 'wmv' 61 | ]; 62 | const boxAudioFormats = ['aac', 'aif', 'aifc', 'aiff', 'amr', 'au', 'flac', 'm4a', 'mp3', 'ra', 'wav', 'wma']; 63 | const boxImageFormats = [ 64 | 'ai', 65 | 'bmp', 66 | 'gif', 67 | 'eps', 68 | 'heic', 69 | 'jpeg', 70 | 'jpg', 71 | 'png', 72 | 'ps', 73 | 'psd', 74 | 'svg', 75 | 'tif', 76 | 'tiff', 77 | 'dcm', 78 | 'dicm', 79 | 'dicom', 80 | 'svs', 81 | 'tga' 82 | ]; 83 | 84 | const getFileFormat = function getFileFormat(fileName) { 85 | const fileExtension = path.extname(fileName).toLowerCase(); 86 | return trimStart(fileExtension, '.'); 87 | }; 88 | const getFileType = function getFileType(fileFormat) { 89 | if (boxAudioFormats.includes(fileFormat)) return FileType.AUDIO.name; 90 | else if (boxImageFormats.includes(fileFormat)) return FileType.IMAGE.name; 91 | else if (boxVideoFormats.includes(fileFormat)) return FileType.VIDEO.name; 92 | return FileType.DOCUMENT.name; 93 | }; 94 | 95 | /** public enums */ 96 | const SkillsErrorEnum = { 97 | FILE_PROCESSING_ERROR: 'skills_file_processing_error', 98 | INVALID_FILE_SIZE: 'skills_invalid_file_size_error', 99 | INVALID_FILE_FORMAT: 'skills_invalid_file_format_error', 100 | INVALID_EVENT: 'skills_invalid_event_error', 101 | NO_INFO_FOUND: 'skills_no_info_found', 102 | INVOCATIONS_ERROR: 'skills_invocations_error', 103 | EXTERNAL_AUTH_ERROR: 'skills_external_auth_error', 104 | BILLING_ERROR: 'skills_billing_error', 105 | UNKNOWN: 'skills_unknown_error' 106 | }; 107 | 108 | /** 109 | * FilesReader :- A helpful client to capture file related information from 110 | * incoming Box Skills event and to access the file's content. 111 | * 112 | * API:- 113 | * FilesReader.getFileContext () : JSON 114 | * FilesReader.validateFormat (allowedFileFormatsList) : boolean 115 | * FilesReader.validateSize (allowedMegabytesNum) : boolean 116 | * async FilesReader.getContentBase64 () : string 117 | * FilesReader.getContentStream () : stream 118 | * async FilesReader.getBasicFormatFileURL () : string 119 | * async FilesReader.getBasicFormatContentBase64 () : string 120 | * FilesReader.getBasicFormatContentStream () : string 121 | * 122 | * Note: BasicFormat functions allows you to access your files stored in Box in 123 | * another format, which may be more accepted by ML providers. The provided basic 124 | * formats are Audio files→.mp3, Document/Image files→.jpeg . Video files→.mp4. 125 | * Caution should be applied using BasicFormats for certain large files as it 126 | * involves a time delay, and your skill code or skills-engine request may 127 | * time out before the converted format is fetched. 128 | */ 129 | 130 | function FilesReader(body) { 131 | const eventBody = JSON.parse(body); 132 | this.requestId = eventBody.id; 133 | this.skillId = eventBody.skill.id; 134 | this.fileId = eventBody.source.id; 135 | this.fileName = eventBody.source.name; 136 | this.fileSize = eventBody.source.size; 137 | this.fileFormat = getFileFormat(this.fileName); 138 | this.fileType = getFileType(this.fileFormat); 139 | this.fileReadToken = eventBody.token.read.access_token; 140 | this.fileWriteToken = eventBody.token.write.access_token; 141 | this.fileReadClient = sdk.getBasicClient(this.fileReadToken); 142 | this.fileDownloadURL = `${BOX_API_ENDPOINT}/files/${this.fileId}/content?access_token=${this.fileReadToken}`; 143 | } 144 | 145 | /** 146 | * SkillsWriter :- A helpful class to write back Metadata Cards for 147 | * Topics, Transcripts, Timelines, Errors and Statuses back to Box for 148 | * any file for which a Skills Event is sent out. 149 | * 150 | * API:- 151 | * SkillsWriter.createTopicsCard ( topicsDataList, optionalFileDuration, optionalCardTitle ) : DataCard json 152 | * SkillsWriter.createTranscriptsCard ( transcriptsDataList, optionalFileDuration, optionalCardTitle ): DataCard json 153 | * async SkillsWriter.createFacesCard ( facesDataList, optionalFileDuration, optionalCardTitle ) : DataCard json 154 | * async SkillsWriter.saveProcessingCard ( optionalCallback ) : null 155 | * async SkillsWriter.saveErrorCard ( error, optionalCustomMessage, optionalCallback ): null 156 | * async SkillsWriter.saveDataCards ( listofDataCardJSONs, optionalCallback): null 157 | */ 158 | function SkillsWriter(fileContext) { 159 | this.requestId = fileContext.requestId; 160 | this.skillId = fileContext.skillId; 161 | this.fileId = fileContext.fileId; 162 | this.fileWriteClient = sdk.getBasicClient(fileContext.fileWriteToken); 163 | } 164 | 165 | /** FilesReader private functions */ 166 | 167 | /** 168 | * reads a ReadStream into a buffer that it then converts to a string 169 | * @param {Object} stream - read stream 170 | * @return Promise - resolves to the string of information read from the stream 171 | */ 172 | const readStreamToString = function readStreamToString(stream) { 173 | if (!stream || typeof stream !== 'object') { 174 | throw new TypeError('Invalid Stream, must be a readable stream.'); 175 | } 176 | return new Promise((resolve, reject) => { 177 | const chunks = []; 178 | stream.on('data', (chunk) => { 179 | chunks.push(chunk); 180 | }); 181 | stream.on('error', (err) => { 182 | reject(err); 183 | }); 184 | stream.on('end', () => { 185 | resolve(Buffer.concat(chunks).toString('base64')); 186 | }); 187 | }); 188 | }; 189 | 190 | /** 191 | * Poll the representation info URL until representation is generated, 192 | * then return content URL template. 193 | * @param {BoxClient} client The client to use for making API calls 194 | * @param {string} infoURL The URL to use for getting representation info 195 | * @returns {Promise} A promise resolving to the content URL template 196 | */ 197 | function pollRepresentationInfo(client, infoURL) { 198 | return client.get(infoURL).then((response) => { 199 | if (response.statusCode !== 200) { 200 | console.error(`Unexpected response ${response}`); 201 | } 202 | const info = response.body; 203 | switch (info.status.state) { 204 | case 'success': 205 | case 'viewable': 206 | case 'error': 207 | return info; 208 | case 'none': 209 | case 'pending': 210 | return Promise.delay(1000).then(() => pollRepresentationInfo(client, infoURL)); 211 | default: 212 | console.error(`Unknown representation status: ${info.status.state}`); 213 | throw new Error(SkillsErrorEnum.FILE_PROCESSING_ERROR); 214 | } 215 | }); 216 | } 217 | 218 | /** FilesReader public functions */ 219 | 220 | /** 221 | * Returns a JSON containing fileId, fileName, fileFormat, fileType, fileSize, fileDownloadURL, 222 | * fileReadToken, fileWriteToken, skillId, requestId for use in code. 223 | */ 224 | FilesReader.prototype.getFileContext = function getFileContext() { 225 | return { 226 | requestId: this.requestId, 227 | skillId: this.skillId, 228 | fileId: this.fileId, 229 | fileName: this.fileName, 230 | fileSize: this.fileSize, 231 | fileFormat: this.fileFormat, 232 | fileType: this.fileType, 233 | fileDownloadURL: this.fileDownloadURL, 234 | fileReadToken: this.fileReadToken, 235 | fileWriteToken: this.fileWriteToken 236 | }; 237 | }; 238 | 239 | /** 240 | * Helper function to check if a given file is eligible to be processed by the 241 | * skill as per the list of allowed formats. 242 | */ 243 | FilesReader.prototype.validateFormat = function validateFormat(allowedFileFormatsList) { 244 | if (allowedFileFormatsList.includes(this.fileFormat)) return true; 245 | console.error(`File format ${this.fileFormat} is not accepted by this skill`); 246 | throw new Error(SkillsErrorEnum.INVALID_FILE_FORMAT); 247 | }; 248 | 249 | /** 250 | * Helper function to check if a given file is eligible to be processed by the skill as per the size limit. 251 | */ 252 | FilesReader.prototype.validateSize = function validateSize(allowedMegabytesNum) { 253 | const fileSizeMB = this.fileSize / MB_INTO_BYTES; 254 | if (fileSizeMB <= allowedMegabytesNum) return true; 255 | console.error(`File size ${fileSizeMB} MB is over accepted limit of ${allowedMegabytesNum} MB`); 256 | throw new Error(SkillsErrorEnum.INVALID_FILE_SIZE); 257 | }; 258 | 259 | /** 260 | * Returns a Read Stream to be passed to read file directly from box. Note: 261 | * Some ML providers support passing file read streams. 262 | */ 263 | FilesReader.prototype.getContentStream = function getContentStream() { 264 | return new Promise((resolve, reject) => { 265 | this.fileReadClient.files.getReadStream(this.fileId, null, (error, stream) => { 266 | if (error) { 267 | reject(error); 268 | } else { 269 | resolve(stream); 270 | } 271 | }); 272 | }); 273 | }; 274 | 275 | /** 276 | * Same as FilesReader.getFileContext().getContentBase64() but in BasicFormat 277 | */ 278 | FilesReader.prototype.getContentBase64 = function getContentBase64() { 279 | return new Promise((resolve, reject) => { 280 | this.getContentStream() 281 | .then((stream) => { 282 | resolve(readStreamToString(stream)); 283 | }) 284 | .then((content) => { 285 | resolve(content); 286 | }) 287 | .catch((e) => { 288 | reject(e); 289 | }); 290 | }); 291 | }; 292 | 293 | /** 294 | * Same as FilesReader.getFileContext().fileDownloadURL but in BasicFormat 295 | */ 296 | FilesReader.prototype.getBasicFormatFileURL = function getBasicFormatFileURL() { 297 | const options = { assetPath: '' }; 298 | 299 | return this.fileReadClient.files 300 | .getRepresentationInfo(this.fileId, FileType[this.fileType].representationType) 301 | .then((reps) => { 302 | const repInfo = reps.entries.pop(); 303 | if (!repInfo) { 304 | console.error('Could not get information for requested representation'); 305 | throw new Error(SkillsErrorEnum.FILE_PROCESSING_ERROR); 306 | } 307 | 308 | switch (repInfo.status.state) { 309 | case 'success': 310 | case 'viewable': 311 | return repInfo.content.url_template; 312 | case 'error': 313 | console.error('Representation had error status'); 314 | throw new Error(SkillsErrorEnum.FILE_PROCESSING_ERROR); 315 | case 'none': 316 | case 'pending': 317 | return pollRepresentationInfo(this.fileReadClient, repInfo.info.url).then((info) => { 318 | if (info.status.state === 'error') { 319 | console.error('Representation had error status'); 320 | throw new Error(SkillsErrorEnum.FILE_PROCESSING_ERROR); 321 | } 322 | return info.content.url_template; 323 | }); 324 | default: 325 | console.error(`Unknown representation status: ${repInfo.status.state}`); 326 | throw new Error(SkillsErrorEnum.FILE_PROCESSING_ERROR); 327 | } 328 | }) 329 | .then( 330 | (assetURLTemplate) => 331 | `${urlTemplate.parse(assetURLTemplate).expand({ asset_path: options.assetPath })}?access_token=${ 332 | this.fileReadToken 333 | }` 334 | ); 335 | }; 336 | 337 | /** 338 | * Same as FilesReader.getFileContext().getContentStream() but in BasicFormat 339 | */ 340 | FilesReader.prototype.getBasicFormatContentStream = function getBasicFormatContentStream() { 341 | return this.fileReadClient.files 342 | .getRepresentationContent(this.fileId, FileType[this.fileType].representationType) 343 | .catch((e) => { 344 | if (e.statusCode === 401) { 345 | throw new TypeError( 346 | 'The client provided is unauthorized. Client should have read access to the file passed' 347 | ); 348 | } 349 | throw e; 350 | }); 351 | }; 352 | 353 | /* 354 | * Same as FilesReader.getFileContext().getContentBase64() but in BasicFormat 355 | */ 356 | FilesReader.prototype.getBasicFormatContentBase64 = function getBasicFormatContentBase64() { 357 | return new Promise((resolve, reject) => { 358 | this.getBasicFormatContentStream() 359 | .then((stream) => { 360 | resolve(readStreamToString(stream)); 361 | }) 362 | .then((content) => { 363 | resolve(content); 364 | }) 365 | .catch((e) => { 366 | reject(e); 367 | }); 368 | }); 369 | }; 370 | 371 | /** SkillsWriter private enums */ 372 | const cardType = { 373 | TRANSCRIPT: 'transcript', 374 | TOPIC: 'keyword', 375 | FACES: 'timeline', 376 | STATUS: 'status', 377 | ERROR: 'error' 378 | }; 379 | 380 | const cardTitle = { 381 | TRANSCRIPT: 'Transcript', 382 | TOPIC: 'Topics', 383 | FACES: 'Faces', 384 | STATUS: 'Status', 385 | ERROR: 'Error' 386 | }; 387 | 388 | const usageUnit = { 389 | FILES: 'files', 390 | SECONDS: 'seconds', 391 | PAGES: 'pages', 392 | WORDS: 'words' 393 | }; 394 | 395 | const skillInvocationStatus = { 396 | INVOKED: 'invoked', 397 | PROCESSING: 'processing', 398 | TRANSIENT_FAILURE: 'transient_failure', 399 | PERMANENT_FAILURE: 'permanent_failure', 400 | SUCCESS: 'success' 401 | }; 402 | 403 | /** SkillsWriter private functions */ 404 | 405 | /** 406 | * validates if Enum value passed exists in the enums 407 | */ 408 | const validateEnum = function validateEnum(inputValue, enumName) { 409 | for (const key in enumName) { 410 | if (enumName[key] === inputValue) return true; 411 | } 412 | return false; 413 | }; 414 | 415 | /** 416 | * Validates if usage object is of allowed format: { unit: , value: } 417 | */ 418 | const validateUsage = function validateUsage(usage) { 419 | return usage && validateEnum(usage.unit, usageUnit) && Number.isInteger(usage.value); 420 | }; 421 | 422 | /** 423 | * Private function to validate and update card template data to have expected fields 424 | */ 425 | const processCardData = function processCardData(cardData, duration) { 426 | if (!cardData.text) throw new TypeError(`Missing required 'text' field in ${JSON.stringify(cardData)}`); 427 | cardData.type = typeof cardData.image_url === 'string' ? 'image' : 'text'; 428 | if (duration && !(Array.isArray(cardData.appears) && cardData.appears.length > 0)) { 429 | console.warn( 430 | `Missing optional 'appears' field in ${JSON.stringify(cardData)} which is list of 'start' and 'end' fields` 431 | ); 432 | } 433 | }; 434 | 435 | /** 436 | * Private function, for underlying call to saving data to skills invocation api 437 | * Will add metadata cards to the file and log other values for analysis purposes 438 | * 439 | * API Endpoint: '/skill_invocations/:skillID' 440 | * Method: PUT 441 | * 442 | * @param {BoxSDK} client Box SDK client to call skill invocations apiId 443 | * @param {string} skillId id of the skill for the '/skill_invocations/:skillID' call 444 | * @param {Object} body data to put 445 | * @param {Function} callback (optional) called with updated metadata if successful 446 | * @return {Promise} promise resolving to the updated metadata 447 | */ 448 | const putData = function putData(client, skillId, body, callback) { 449 | const apiPath = urlPath(BASE_PATH, skillId); 450 | const params = { 451 | body, 452 | headers: { 453 | 'Cache-Control': 'no-cache', 454 | 'Content-Type': 'application/json' 455 | } 456 | }; 457 | return client.wrapWithDefaultHandler(client.put)(apiPath, params, callback); 458 | }; 459 | 460 | /** SkillsWriter public functions */ 461 | 462 | /** 463 | * Public function to return a complete metadata card 464 | * 465 | * @param {string} type type of metadata card (status, transcript, etc.) 466 | * @param {string} title title of metadata card (Status, Transcript, etc.) 467 | * @param {Object} optionalStatus (optional) status object with code and message 468 | * @param {Object} optionalEntries (optional) list of cards being saved 469 | * @param {number} optionalfileDuration (optional) total duration of file in seconds 470 | * @return {Object} metadata card template 471 | */ 472 | SkillsWriter.prototype.createMetadataCard = function createMetadataCard( 473 | type, 474 | title, 475 | optionalStatus, 476 | optionalEntries, 477 | optionalfileDuration 478 | ) { 479 | const status = optionalStatus || {}; 480 | const titleCode = `skills_${title.toLowerCase()}`.replace(' ', '_'); 481 | const template = { 482 | created_at: new Date().toISOString(), 483 | type: SKILLS_METADATA_CARD_TYPE, // skill_card 484 | skill: { 485 | type: SKILLS_SERVICE_TYPE, // service 486 | id: this.skillId 487 | }, 488 | skill_card_type: type, 489 | skill_card_title: { 490 | code: titleCode, 491 | message: title 492 | }, 493 | invocation: { 494 | type: SKILLS_METADATA_INVOCATION_TYPE, // skill_invocation 495 | id: this.requestId 496 | }, 497 | status 498 | }; 499 | if (optionalEntries) { 500 | template.entries = optionalEntries; 501 | } 502 | if (optionalfileDuration) { 503 | template.duration = parseFloat(optionalfileDuration); 504 | } 505 | return template; 506 | }; 507 | 508 | SkillsWriter.prototype.createTopicsCard = function createTopicsCard( 509 | topicsDataList, 510 | optionalFileDuration, 511 | optionalCardTitle 512 | ) { 513 | topicsDataList.forEach((topic) => processCardData(topic, optionalFileDuration)); 514 | return this.createMetadataCard( 515 | cardType.TOPIC, 516 | optionalCardTitle || cardTitle.TOPIC, 517 | {}, // Empty status value, since this is a data card 518 | topicsDataList, 519 | optionalFileDuration 520 | ); 521 | }; 522 | 523 | SkillsWriter.prototype.createTranscriptsCard = function createTranscriptsCard( 524 | transcriptsDataList, 525 | optionalFileDuration, 526 | optionalCardTitle 527 | ) { 528 | transcriptsDataList.forEach((transcript) => processCardData(transcript, optionalFileDuration)); 529 | return this.createMetadataCard( 530 | cardType.TRANSCRIPT, 531 | optionalCardTitle || cardTitle.TRANSCRIPT, 532 | {}, // Empty status value, since this is a data card 533 | transcriptsDataList, 534 | optionalFileDuration 535 | ); 536 | }; 537 | 538 | SkillsWriter.prototype.createFacesCard = function createFacesCard( 539 | facesDataList, 540 | optionalFileDuration, 541 | optionalCardTitle 542 | ) { 543 | facesDataList.forEach((face) => processCardData(face, optionalFileDuration)); 544 | const cards = this.createMetadataCard( 545 | cardType.FACES, 546 | optionalCardTitle || cardTitle.FACES, 547 | {}, // Empty status value, since this is a data card 548 | facesDataList, 549 | optionalFileDuration 550 | ); 551 | 552 | const dataURIPromises = []; 553 | for (let i = 0; i < facesDataList.length; i++) { 554 | dataURIPromises.push( 555 | jimp 556 | .read(facesDataList[i].image_url) 557 | .then((image) => 558 | // resize the image to be thumbnail size 559 | image.resize(45, 45).getBase64Async(jimp.MIME_PNG) 560 | ) 561 | // promise.all rejects if one of the promises in the array gets rejected, 562 | // without considering whether or not the other promises have resolved. 563 | // This is to make sure Promise.all continues evluating all promises inspite some rejections. 564 | 565 | .catch(() => undefined) 566 | ); 567 | } 568 | 569 | return new Promise((resolve, reject) => { 570 | Promise.all(dataURIPromises) 571 | .then((dataURIs) => { 572 | for (let i = 0; i < facesDataList.length; i++) { 573 | facesDataList[i].image_url = dataURIs[i] || facesDataList[i].image_url; 574 | } 575 | resolve(cards); 576 | }) 577 | .catch((error) => { 578 | reject(error); 579 | }); 580 | }); 581 | }; 582 | 583 | /** 584 | * Shows UI card with message: "We're preparing to process your file. Please hold on!". 585 | * This is used for temporarily letting your users know that your skill is under progress. 586 | * You can pass an optionalCallback function to print or log success in your code once the 587 | * card has been saved. 588 | */ 589 | SkillsWriter.prototype.saveProcessingCard = function saveProcessingCard(optionalCallback) { 590 | const status = { 591 | code: 'skills_pending_status', 592 | message: "We're preparing to process your file. Please hold on!" 593 | }; 594 | const statusCard = this.createMetadataCard(cardType.STATUS, cardTitle.STATUS, status); 595 | return this.saveDataCards([statusCard], optionalCallback, skillInvocationStatus.PROCESSING); 596 | }; 597 | 598 | /** 599 | * Show UI card with error message. See Table: ErrorCode Enum for potential errorCode values, 600 | * to notify user if any kind of failure occurs while running your skills code. Shows card as 601 | * per the default message with each code, unless 'optionMessage' is provided. You can pass an 602 | * optionalCallback function to print or log success in your code once the card has been saved. 603 | */ 604 | SkillsWriter.prototype.saveErrorCard = function saveErrorCard( 605 | error, 606 | optionalCustomErrorMessage, 607 | optionalCallback, 608 | optionalFailureType 609 | ) { 610 | const failureType = 611 | optionalFailureType === skillInvocationStatus.TRANSIENT_FAILURE 612 | ? optionalFailureType 613 | : skillInvocationStatus.PERMANENT_FAILURE; 614 | const errorCode = validateEnum(error, SkillsErrorEnum) ? error : SkillsErrorEnum.UNKNOWN; 615 | let errorObj = { code: errorCode }; 616 | if (optionalCustomErrorMessage) { 617 | errorObj = { code: 'custom_error', message: optionalCustomErrorMessage }; 618 | } 619 | const errorCard = this.createMetadataCard(cardType.STATUS, cardTitle.ERROR, errorObj); 620 | return this.saveDataCards([errorCard], optionalCallback, failureType); 621 | }; 622 | 623 | /** 624 | * Shows all the cards passed in listofDataCardJSONs which can be of formatted as Topics,Transcripts 625 | * or Faces. Will override any existing pending or error status cards in the UI for that file version. 626 | */ 627 | const DEFAULT_USAGE = { unit: usageUnit.FILES, value: 1 }; 628 | SkillsWriter.prototype.saveDataCards = function saveDataCards( 629 | listofDataCardJSONs, 630 | optionalCallback, 631 | optionalStatus, 632 | optionalUsage 633 | ) { 634 | const status = validateEnum(optionalStatus, skillInvocationStatus) ? optionalStatus : skillInvocationStatus.SUCCESS; 635 | let usage = null; 636 | if (status === skillInvocationStatus.SUCCESS) { 637 | usage = validateUsage(optionalUsage) ? optionalUsage : DEFAULT_USAGE; 638 | } 639 | // create skill_invocations body 640 | const body = { 641 | status, 642 | file: { 643 | type: 'file', 644 | id: this.fileId 645 | }, 646 | metadata: { 647 | cards: listofDataCardJSONs 648 | }, 649 | usage 650 | }; 651 | return putData(this.fileWriteClient, this.skillId, body, optionalCallback); 652 | }; 653 | 654 | /* Exporting useful functions and enums from skills-kit plugin */ 655 | module.exports = { 656 | FilesReader, 657 | SkillsWriter, 658 | SkillsErrorEnum 659 | }; 660 | --------------------------------------------------------------------------------