From 221ae40b94e2a2072a8b324158926f1b67cb02d3 Mon Sep 17 00:00:00 2001 From: stevkan Date: Fri, 16 Mar 2018 13:52:45 -0700 Subject: [PATCH] initial commit of public version --- .gitignore | 6 + .vs/Coffee-Bot/v15/.suo | Bin 0 -> 32768 bytes .vs/ProjectSettings.json | 3 + .vs/VSWorkspaceState.json | 7 + .vs/slnx.sqlite | Bin 0 -> 73728 bytes PostDeployScripts/githubProject.json.template | 9 + PostDeployScripts/prepareSrc.cmd | 58 ++++ PostDeployScripts/publish.js.template | 52 ++++ PostDeployScripts/runGulp.cmd | 28 ++ PostDeployScripts/setupGithubRemoteRepo.cmd | 44 +++ PostDeployScripts/setupVsoRemoteRepo.cmd | 50 +++ PostDeployScripts/vsoProject.json.template | 12 + README.md | 27 ++ app.js | 284 ++++++++++++++++++ iisnode.yml | 1 + package.json | 26 ++ publish.js | 52 ++++ web.config | 61 ++++ 18 files changed, 720 insertions(+) create mode 100644 .gitignore create mode 100644 .vs/Coffee-Bot/v15/.suo create mode 100644 .vs/ProjectSettings.json create mode 100644 .vs/VSWorkspaceState.json create mode 100644 .vs/slnx.sqlite create mode 100644 PostDeployScripts/githubProject.json.template create mode 100644 PostDeployScripts/prepareSrc.cmd create mode 100644 PostDeployScripts/publish.js.template create mode 100644 PostDeployScripts/runGulp.cmd create mode 100644 PostDeployScripts/setupGithubRemoteRepo.cmd create mode 100644 PostDeployScripts/setupVsoRemoteRepo.cmd create mode 100644 PostDeployScripts/vsoProject.json.template create mode 100644 README.md create mode 100644 app.js create mode 100644 iisnode.yml create mode 100644 package.json create mode 100644 publish.js create mode 100644 web.config diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3ab1ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.vscode/ +node_modules/ +build/ +tmp/ +temp/ \ No newline at end of file diff --git a/.vs/Coffee-Bot/v15/.suo b/.vs/Coffee-Bot/v15/.suo new file mode 100644 index 0000000000000000000000000000000000000000..6c35b1a22f0f605b9c6e1b5996997d6613dd1605 GIT binary patch literal 32768 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 Y0u%v?07ZZzKoOt_Py{FfuMYzMAIdsBMF0Q* literal 0 HcmV?d00001 diff --git a/PostDeployScripts/githubProject.json.template b/PostDeployScripts/githubProject.json.template new file mode 100644 index 0000000..953220a --- /dev/null +++ b/PostDeployScripts/githubProject.json.template @@ -0,0 +1,9 @@ +{ + "name": "{WEB_SITE_NAME}", + "description": "{WEB_SITE_NAME} Azure Bot Service Code", + "homepage": "https://github.com", + "private": false, + "has_issues": true, + "has_projects": true, + "has_wiki": true +} \ No newline at end of file diff --git a/PostDeployScripts/prepareSrc.cmd b/PostDeployScripts/prepareSrc.cmd new file mode 100644 index 0000000..2290e64 --- /dev/null +++ b/PostDeployScripts/prepareSrc.cmd @@ -0,0 +1,58 @@ +rem @echo off +setlocal +SET password=%1 +SET repoName=srcRepo +SET repoUrl=file:///%HOMEDRIVE:~0,1%/%HOMEPATH:~1%/site/%repoName% +SET download=bot-src + +echo %repoUrl% + +rem cd to project root +pushd ..\wwwroot + +rem init git +call git init +call git config user.name "botframework" +call git config user.email "util@botframework.com" +call git add . +call git commit -m "prepare to download source" +call git remote add srcRepo %repoUrl% +popd + +rem init upstream +pushd %HOME%\site +mkdir srcRepo +cd srcRepo +call git init --bare +popd + +rem push to upstream +pushd ..\wwwroot +call git push --set-upstream srcRepo master +popd + +rem clone srcRepo +pushd %HOME%\site +call git clone %repoUrl% %download% +rem delete .git +cd %download% +call rm -r -f .git +popd + +rem prepare for publish +type PostDeployScripts\publish.js.template | sed -e s/\{WEB_SITE_NAME\}/%WEBSITE_SITE_NAME%/g | sed -e s/\{PASSWORD\}/%password%/g > %HOME%\site\%download%\publish.js + +rem preare the zip file +%HOMEDRIVE%\7zip\7za a %HOME%\site\%download%.zip %HOME%\site\%download%\* + +rem cleanup git stuff +pushd ..\wwwroot +call rm -r -f .git +popd + +pushd %HOME%\site +call rm -r -f %download% +call rm -r -f %repoName% +popd + +endlocal diff --git a/PostDeployScripts/publish.js.template b/PostDeployScripts/publish.js.template new file mode 100644 index 0000000..0f635f3 --- /dev/null +++ b/PostDeployScripts/publish.js.template @@ -0,0 +1,52 @@ +var zipFolder = require('zip-folder'); +var path = require('path'); +var fs = require('fs'); +var request = require('request'); + +var rootFolder = path.resolve('.'); +var zipPath = path.resolve(rootFolder, '../{WEB_SITE_NAME}.zip'); +var kuduApi = 'https://{WEB_SITE_NAME}.scm.azurewebsites.net/api/zip/site/wwwroot'; +var userName = '${WEB_SITE_NAME}'; +var password = '{PASSWORD}'; + +function uploadZip(callback) { + fs.createReadStream(zipPath).pipe(request.put(kuduApi, { + auth: { + username: userName, + password: password, + sendImmediately: true + }, + headers: { + "Content-Type": "applicaton/zip" + } + })) + .on('response', function(resp){ + if (resp.statusCode >= 200 && resp.statusCode < 300) { + fs.unlink(zipPath); + callback(null); + } else if (resp.statusCode >= 400) { + callback(resp); + } + }) + .on('error', function(err) { + callback(err) + }); +} + +function publish(callback) { + zipFolder(rootFolder, zipPath, function(err) { + if (!err) { + uploadZip(callback); + } else { + callback(err); + } + }) +} + +publish(function(err) { + if (!err) { + console.log('{WEB_SITE_NAME} publish'); + } else { + console.error('failed to publish {WEB_SITE_NAME}', err); + } +}); \ No newline at end of file diff --git a/PostDeployScripts/runGulp.cmd b/PostDeployScripts/runGulp.cmd new file mode 100644 index 0000000..5b9fd79 --- /dev/null +++ b/PostDeployScripts/runGulp.cmd @@ -0,0 +1,28 @@ +@echo off +setlocal + +if exist ..\wwwroot\package.json ( + pushd ..\wwwroot + echo npm install --production + call npm install --production + popd +) + +for /d %%d in (..\wwwroot\*) do ( + echo check %%d + pushd %%d + if exist package.json ( + echo npm install --production + call npm install --production + ) else ( + echo no package.json found + ) + popd +) + +echo record deployment timestamp +date /t >> ..\deployment.log +time /t >> ..\deployment.log +echo ---------------------- >> ..\deployment.log +echo Deployment done + diff --git a/PostDeployScripts/setupGithubRemoteRepo.cmd b/PostDeployScripts/setupGithubRemoteRepo.cmd new file mode 100644 index 0000000..f6a1f50 --- /dev/null +++ b/PostDeployScripts/setupGithubRemoteRepo.cmd @@ -0,0 +1,44 @@ +@echo off +setlocal +rem ------------------------------------------------------------------------------------------ +rem setupVsoRemoteRepo [remoteUser] [personalAccessToken] [projName{optional}] +rem create and populate VSO git repo for the ABS code instance +rem +rem remoteUser: user account name of the personal access token +rem personalAccessToken: the personal access token used to access github REST API (requires repos scope) +rem projName the name of the project to create (default to WEBSITE_SITE_NAME) +rem ------------------------------------------------------------------------------------------ +set remoteUrl=https://api.github.com +set remoteUser=%1 +set remotePwd=%2 +set projName=%3 +if '%projName%'=='' set projName=%WEBSITE_SITE_NAME% +set repoUrl=https://%remoteUser%:%remotePwd%@github.com/%remoteUser%/%projName%.git +rem use curl to create project +pushd ..\wwwroot +type PostDeployScripts\githubProject.json.template | sed -e s/\{WEB_SITE_NAME\}/%projName%/g > %TEMP%\githubProject.json +call curl -H "Content-Type: application/json" -u %remoteUser%:%remotePwd% -d "@%TEMP%\githubProject.json" -X POST %remoteUrl%/user/repos +rem rm %TEMP%\githubProject.json +popd + +popd +rem cd to project root +pushd ..\wwwroot + +rem init git +call git init +call git config user.name "%remoteUser%" +call git config user.password "%remotePwd%" +call git config user.email "util@botframework.com" +call git add . +call git commit -m "prepare to setup source control" +call git push %repoUrl% master +popd + + +rem cleanup git stuff +pushd ..\wwwroot +call rm -r -f .git +popd + +endlocal \ No newline at end of file diff --git a/PostDeployScripts/setupVsoRemoteRepo.cmd b/PostDeployScripts/setupVsoRemoteRepo.cmd new file mode 100644 index 0000000..4fa1ac1 --- /dev/null +++ b/PostDeployScripts/setupVsoRemoteRepo.cmd @@ -0,0 +1,50 @@ +@echo off +setlocal +rem ------------------------------------------------------------------------------------------ +rem setupVsoRemoteRepo [vsoRemote] [vsoUserName] [vsoPersonalAccessToken] [projName{optional}] +rem create and populate VSO git repo for the ABS code instance +rem +rem vsoRmote: url of the VSO site (e.g. https://awesomebot.visualstudio.com ) +rem vosUserName: user account name of the personal access token +rem vsoPersonalAccessToken: the personal access token used to access VSO REST api +rem projName the name of the project to create (default to WEBSITE_SITE_NAME) +rem ------------------------------------------------------------------------------------------ +set remoteUrl=%1 +set remoteUser=%2 +set remotePwd=%3 +set projName=%4 +if '%projName%'=='' set projName=%WEBSITE_SITE_NAME% +set vstsRoot=%remoteUrl% +set repoUrl=https://%remoteUser%:%remotePwd%@%remoteUrl:~8%/_git/%projName% +set vstsCreateProject=https://%remoteUser%:%remotePwd%@%remoteUrl:~8%/defaultcollection/_apis/projects?api-version=3.0 + +rem use curl to create project +pushd ..\wwwroot +type PostDeployScripts\vsoProject.json.template | sed -e s/\{WEB_SITE_NAME\}/%projName%/g > %TEMP%\vsoProject.json +call curl -H "Content-Type: application/json" -d "@%TEMP%\vsoProject.json" -X POST %vstsCreateProject% +rm %TEMP%\vsoProject.json +rem sleep for 15 seconds for the creation to complete, this is a wild guess +call sleep 15 +popd + +popd +rem cd to project root +pushd ..\wwwroot + +rem init git +call git init +call git config user.name "%remoteUser%" +call git config user.password "%remotePwd%" +call git config user.email "util@botframework.com" +call git add . +call git commit -m "prepare to setup source control" +call git push %repoUrl% master +popd + + +rem cleanup git stuff +pushd ..\wwwroot +call rm -r -f .git +popd + +endlocal \ No newline at end of file diff --git a/PostDeployScripts/vsoProject.json.template b/PostDeployScripts/vsoProject.json.template new file mode 100644 index 0000000..e7bcdc9 --- /dev/null +++ b/PostDeployScripts/vsoProject.json.template @@ -0,0 +1,12 @@ +{ + "name": "{WEB_SITE_NAME}", + "description": "{WEB_SITE_NAME} Azure Bot Service Code", + "capabilities": { + "versioncontrol": { + "sourceControlType": "Git" + }, + "processTemplate": { + "templateTypeId": "6b724908-ef14-45cf-84f8-768b5384da45" + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce9f6f9 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +## Use Azure app service editor + +1. make code change in the online editor + +Your code changes go live as the code changes are saved. + +## Use Visual Studio Code + +### Build and debug +1. download source code zip and extract source in local folder +2. open the source folder in Visual Studio Code +3. make code changes +4. download and run [botframework-emulator](https://emulator.botframework.com/) +5. connect the emulator to http://localhost:3987 + +### Publish back + +``` +npm run azure-publish +``` + +## Use continuous integration + +If you have setup continuous integration, then your bot will automatically deployed when new changes are pushed to the source repository. + + + diff --git a/app.js b/app.js new file mode 100644 index 0000000..3f12061 --- /dev/null +++ b/app.js @@ -0,0 +1,284 @@ +/*----------------------------------------------------------------------------- +A simple echo bot for the Microsoft Bot Framework. +-----------------------------------------------------------------------------*/ +var restify = require('restify'); +var builder = require('botbuilder'); +var azure = require("botbuilder-azure"); +var builder_cognitiveservices = require('botbuilder-cognitiveservices'); + + +// Setup Restify Server +var server = restify.createServer(); +server.listen(process.env.port || process.env.PORT || 3978, function() { + console.log('%s listening to %s', server.name, server.url); +}); + +// Create chat connector for communicating with the Bot Framework Service +var connector = new builder.ChatConnector({ + appId: process.env.MicrosoftAppId, + appPassword: process.env.MicrosoftAppPassword, + openIdMetadata: process.env.BotOpenIdMetadata +}); + +// Listen for messages from users +server.post('/api/messages', connector.listen()); + +/* Legacy table storage connection credentials to be removed at later point */ +// var tableName = 'botdata'; +// var azureTableClient = new azure.AzureTableClient(tableName, process.env['AzureWebJobsStorage']); +// var tableStorage = new azure.AzureBotStorage({ gzipData: false }, azureTableClient); + +// Cosmos Db connection credentials +var documentDbOptions = { + host: 'https://***********.documents.azure.com:443/', + masterKey: '*********************', + database: '**********', + collection: '*********' +}; +var docDbClient = new azure.DocumentDbClient(documentDbOptions, { + masterKey: documentDbOptions.masterKey +}); +var cosmosStorage = new azure.AzureBotStorage({ gzipData: false }, docDbClient); + +// Creates bot using Cosmos for saving state data +var bot = new builder.UniversalBot(connector).set('storage', cosmosStorage); + +// Calls bot upon start up +bot.on('conversationUpdate', function(message) { + if (message.membersAdded) { + message.membersAdded.forEach(function(identity) { + if (identity.id === message.address.bot.id) { + bot.beginDialog(message.address, '/'); + } + }); + } +}); + +const logUserConversation = (event) => { + console.log('message: ' + event.text + ', user: ' + event.address.user.name); + console.log("Event", JSON.stringify(event, null, 4)); +}; + +// Middleware for logging +bot.use({ + receive: function(event, next) { + console.log("Received from user:"); + logUserConversation(event); + next(); + }, + send: function(event, next) { + console.log("Sent by bot:"); + logUserConversation(event); + next(); + } +}); + +// Loads the bots opening message and graphic +bot.dialog('/', [ + function(session) { + var welcomeCard = new builder.HeroCard(session) + .title('How can I help you?') + .images([ + new builder.CardImage(session) + .url('https://2.bp.blogspot.com/-I0rdxZj_dwk/UFZQs22fSBI/AAAAAAAAAKw/byN1OWiehWI/s1600/ToffeeMocha.JPG') + .alt('Mocha') + ]) + .buttons([ + builder.CardAction.imBack(session, "order coffee", "Order a Coffee") + ]); + session.send(new builder.Message(session).addAttachment(welcomeCard)); + } +]) + +// Dialog for ordering coffee(s) and special actions +bot.dialog('orderCoffee', [ + function(session, args) { + if (!args.continueOrder) { + session.userData.cart = []; + } + session.send("At anytime you can say 'cancel order', 'view cart', or 'checkout'."); + builder.Prompts.choice(session, "What coffee would you like to order?", "Drip|Espresso|Mocha", { listStyle: builder.ListStyle.button }); + }, + function(session, results) { + session.dialogData.coffeeType = results.response.entity; + session.beginDialog('order' + session.dialogData.coffeeType.toString()); + }, + function(session, results) { + if (results.response) { + session.userData.cart.push(results.response); + } + session.replaceDialog('orderCoffee', { continueOrder: true }); + } + ]).triggerAction({ + matches: /order.*coffee/i, + confirmPrompt: "This will cancel the current order. Are you sure?" + }) + .cancelAction('cancelOrderAction', "Order canceled.", { + matches: /(cancel.*order|^cancel)/i, + confirmPrompt: "Are you sure?" + }) + .beginDialogAction('viewCartAction', 'viewCartDialog', { + matches: /view.*cart/i + }) + .beginDialogAction('checkoutAction', 'checkoutDialog', { + matches: /checkout/i, + matches: /check.*out/i + }); + +// Dialog for ordering drop coffee +bot.dialog('orderDrip', [ + function(session) { + session.dialogData.coffeeType = 'drip'; + session.send("Drip coffees only come in two sizes.") + builder.Prompts.choice(session, "Which size would you like?", "12 oz.|16 oz.", { listStyle: builder.ListStyle.button }); + }, + function(session, results) { + session.dialogData.coffeeSize = results.response.entity; + builder.Prompts.text(session, "What is your name? I'd like to add it to your order.") + }, + function(session, results) { + session.dialogData.customerName = results.response.charAt(0).toUpperCase() + results.response.slice(1).toLowerCase(); + var uuid = (S4() + S4() + "-" + S4() + "-4" + S4().substr(0, 3) + "-" + S4() + "-" + S4() + S4() + S4()).toLowerCase(); + var item = { + order: session.dialogData.coffeeSize + ' ' + session.dialogData.coffeeType + ' coffee', + coffee: session.dialogData.coffeeType, + size: session.dialogData.coffeeSize, + flavor: null, + shots: null, + name: session.dialogData.customerName, + guid: uuid + }; + session.send('\n* %s added for %s', item.order, item.name); + session.endDialogWithResult({ response: item }); + } +]).cancelAction('cancelItemAction', "Item canceled.", { + matches: /(cancel.*item|^cancel)/i +}); + +// Dialog for ordering espress coffee +bot.dialog('orderEspresso', [ + function(session) { + session.dialogData.coffeeType = 'espresso'; + builder.Prompts.choice(session, "What size?", "12 oz.|16 oz.|24 oz.", { listStyle: builder.ListStyle.button }); + }, + function(session, results) { + session.dialogData.coffeeSize = results.response.entity; + builder.Prompts.choice(session, "How many shots would you like?", "One|Two|Three|Four", { listStyle: builder.ListStyle.button }); + }, + function(session, results) { + session.dialogData.coffeeShots = results.response.entity.toLowerCase(); + builder.Prompts.text(session, "What is your name? I'd like to add it to your order.") + }, + function(session, results) { + session.dialogData.customerName = results.response.charAt(0).toUpperCase() + results.response.slice(1).toLowerCase(); + var uuid = (S4() + S4() + "-" + S4() + "-4" + S4().substr(0, 3) + "-" + S4() + "-" + S4() + S4() + S4()).toLowerCase(); + var item = { + order: session.dialogData.coffeeSize + ' ' + session.dialogData.coffeeType + ' with ' + + session.dialogData.coffeeShots + ' shot(s)', + coffee: session.dialogData.coffeeType, + size: session.dialogData.coffeeSize, + flavor: null, + shots: session.dialogData.coffeeShots, + name: session.dialogData.customerName, + guid: uuid + }; + session.send('\n* %s added for %s', item.order, item.name); + session.endDialogWithResult({ response: item }); + } +]).cancelAction('cancelItemAction', "Item canceled.", { + matches: /(cancel.*item|^cancel)/i +}); + +// Dialog for ordering a mocha coffee +bot.dialog('orderMocha', [ + function(session) { + session.dialogData.coffeeType = 'mocha'; + builder.Prompts.choice(session, "What size?", "12 oz.|16 oz.|24 oz.", { listStyle: builder.ListStyle.button }); + }, + function(session, results) { + session.dialogData.coffeeSize = results.response.entity; + builder.Prompts.choice(session, "Would you like to add a flavor?", "Vanilla|Hazelnut|Raspberry|None", { listStyle: builder.ListStyle.button }); + }, + function(session, results) { + session.dialogData.coffeeFlavor = results.response.entity.toLowerCase(); + builder.Prompts.choice(session, "How many shots would you like?", "One|Two|Three|Four", { listStyle: builder.ListStyle.button }); + }, + function(session, results) { + session.dialogData.coffeeShots = results.response.entity.toLowerCase(); + builder.Prompts.text(session, "What is your name? I'd like to add it to your order.") + }, + function(session, results) { + session.dialogData.customerName = results.response.charAt(0).toUpperCase() + results.response.slice(1).toLowerCase(); + var uuid = (S4() + S4() + "-" + S4() + "-4" + S4().substr(0, 3) + "-" + S4() + "-" + S4() + S4() + S4()).toLowerCase(); + if (session.dialogData.coffeeFlavor == 'none') { + var item = { + order: session.dialogData.coffeeSize + ' ' + session.dialogData.coffeeType + ' with ' + + 'no flavor added and ' + + session.dialogData.coffeeShots + ' shot(s)', + coffee: session.dialogData.coffeeType, + size: session.dialogData.coffeeSize, + flavor: 'none', + shots: session.dialogData.coffeeShots, + name: session.dialogData.customerName, + guid: uuid + }; + } else { + var item = { + order: session.dialogData.coffeeSize + ' ' + session.dialogData.coffeeType + ' with ' + + session.dialogData.coffeeFlavor + ' flavor and ' + + session.dialogData.coffeeShots + ' shot(s)', + coffee: session.dialogData.coffeeType, + size: session.dialogData.coffeeSize, + flavor: session.dialogData.coffeeFlavor, + shots: session.dialogData.coffeeShots, + name: session.dialogData.customerName, + guid: uuid + }; + } + session.send('\n* %s added for %s', item.order, item.name); + session.endDialogWithResult({ response: item }); + } +]).cancelAction('cancelItemAction', "Item canceled.", { + matches: /(cancel.*item|^cancel)/i +}); + +// Dialog for showing the users cart +bot.dialog('viewCartDialog', [ + function(session) { + var msg; + var cart = session.userData.cart; + if (cart.length > 0) { + msg = "Items in your cart:"; + for (var i = 0; i < cart.length; i++) { + msg += "\n* " + cart[i].order + " for " + cart[i].name; + } + } else { + msg = "Your cart is empty."; + } + session.endDialog(msg); + } +]); + +// Dialog for checking out +bot.dialog('checkoutDialog', [ + function(session) { + var cart = session.userData.cart; + if (cart.length > 0) { + session.send('Order confirmed.') + for (i = 0; i < cart.length; i++) { + session.send(` + Coffee: ${cart[i].order} + Customer name: ${cart[i].name}`) + } + } else { + msg = "Your cart is empty."; + } + delete session.userData.cart; + session.endConversation('Thank you for your order!'); + } +]); + +// Function for creating a guid +function S4() { + return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); +}; \ No newline at end of file diff --git a/iisnode.yml b/iisnode.yml new file mode 100644 index 0000000..cec5e5c --- /dev/null +++ b/iisnode.yml @@ -0,0 +1 @@ +nodeProcessCommandLine: "D:\Program Files (x86)\nodejs\6.9.1\node.exe" \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..b83ae9d --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "coffeebot", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Steven Kanberg", + "license": "ISC", + "dependencies": { + "adaptivecards": "^1.0.0-beta11", + "azure-storage": "^2.8.0", + "botbuilder": "^3.13.1", + "botbuilder-azure": "^3.0.4", + "botbuilder-cognitiveservices": "^1.1.0", + "mssql": "^4.1.0", + "reflect-metadata": "^0.1.12", + "restify": "^5.0.0" + }, + "devDependencies": { + "request": "^2.81.0", + "typescript": "^2.7.2", + "zip-folder": "^1.0.0" + } +} \ No newline at end of file diff --git a/publish.js b/publish.js new file mode 100644 index 0000000..40f7b99 --- /dev/null +++ b/publish.js @@ -0,0 +1,52 @@ +var zipFolder = require('zip-folder'); +var path = require('path'); +var fs = require('fs'); +var request = require('request'); + +var rootFolder = path.resolve('.'); +var zipPath = path.resolve(rootFolder, '../ordercoffeebot.zip'); +var kuduApi = 'https://ordercoffeebot.scm.azurewebsites.net/api/zip/site/wwwroot'; +var userName = '$ordercoffeebot'; +var password = 'Kn7GndSjvbR2P5tv0HnWDnegit9yf66inRiL3eZv7zl0DnAmS5ucsb5G9pfy'; + +function uploadZip(callback) { + fs.createReadStream(zipPath).pipe(request.put(kuduApi, { + auth: { + username: userName, + password: password, + sendImmediately: true + }, + headers: { + "Content-Type": "applicaton/zip" + } + })) + .on('response', function(resp){ + if (resp.statusCode >= 200 && resp.statusCode < 300) { + fs.unlink(zipPath); + callback(null); + } else if (resp.statusCode >= 400) { + callback(resp); + } + }) + .on('error', function(err) { + callback(err) + }); +} + +function publish(callback) { + zipFolder(rootFolder, zipPath, function(err) { + if (!err) { + uploadZip(callback); + } else { + callback(err); + } + }) +} + +publish(function(err) { + if (!err) { + console.log('ordercoffeebot publish'); + } else { + console.error('failed to publish ordercoffeebot', err); + } +}); \ No newline at end of file diff --git a/web.config b/web.config new file mode 100644 index 0000000..10f2056 --- /dev/null +++ b/web.config @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +