├── .gitignore ├── ApprovalBot.sln ├── ApprovalBot ├── App_Start │ └── WebApiConfig.cs ├── ApprovalBot.csproj ├── Controllers │ ├── MessagesController.cs │ └── ResponsesController.cs ├── Dialogs │ ├── ExceptionHandlerDialog.cs │ └── RootDialog.cs ├── Global.asax ├── Global.asax.cs ├── Helpers │ ├── ApprovalRequestHelper.cs │ ├── ApprovalStatusHelper.cs │ ├── DatabaseHelper.cs │ ├── EmailHelper.cs │ ├── GraphHelper.cs │ └── TimeZoneHelper.cs ├── Models │ ├── ActionData.cs │ ├── ActionableEmailResponse.cs │ ├── Approval.cs │ ├── ApproverInfo.cs │ └── FileInfo.cs ├── OutlookAdaptive │ ├── AdaptiveActionSet.cs │ ├── AdaptiveHttpAction.cs │ └── AdaptiveToggleVisibilityAction.cs ├── PrivateSettings.example.config ├── Properties │ └── AssemblyInfo.cs ├── Web.Debug.config ├── Web.Release.config ├── Web.config ├── default.htm └── packages.config ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md └── readme-images ├── aad-application-id.PNG ├── aad-copy-client-secret.png ├── aad-new-client-secret.png ├── aad-portal-app-registrations.png ├── aad-register-an-app.PNG ├── configure-emulator.PNG ├── hello-bot.PNG ├── ngrok1.PNG └── ngrok2.PNG /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # Benchmark Results 46 | BenchmarkDotNet.Artifacts/ 47 | 48 | # .NET Core 49 | project.lock.json 50 | project.fragment.lock.json 51 | artifacts/ 52 | **/Properties/launchSettings.json 53 | 54 | *_i.c 55 | *_p.c 56 | *_i.h 57 | *.ilk 58 | *.meta 59 | *.obj 60 | *.pch 61 | *.pdb 62 | *.pgc 63 | *.pgd 64 | *.rsp 65 | *.sbr 66 | *.tlb 67 | *.tli 68 | *.tlh 69 | *.tmp 70 | *.tmp_proj 71 | *.log 72 | *.vspscc 73 | *.vssscc 74 | .builds 75 | *.pidb 76 | *.svclog 77 | *.scc 78 | 79 | # Chutzpah Test files 80 | _Chutzpah* 81 | 82 | # Visual C++ cache files 83 | ipch/ 84 | *.aps 85 | *.ncb 86 | *.opendb 87 | *.opensdf 88 | *.sdf 89 | *.cachefile 90 | *.VC.db 91 | *.VC.VC.opendb 92 | 93 | # Visual Studio profiler 94 | *.psess 95 | *.vsp 96 | *.vspx 97 | *.sap 98 | 99 | # TFS 2012 Local Workspace 100 | $tf/ 101 | 102 | # Guidance Automation Toolkit 103 | *.gpState 104 | 105 | # ReSharper is a .NET coding add-in 106 | _ReSharper*/ 107 | *.[Rr]e[Ss]harper 108 | *.DotSettings.user 109 | 110 | # JustCode is a .NET coding add-in 111 | .JustCode 112 | 113 | # TeamCity is a build add-in 114 | _TeamCity* 115 | 116 | # DotCover is a Code Coverage Tool 117 | *.dotCover 118 | 119 | # AxoCover is a Code Coverage Tool 120 | .axoCover/* 121 | !.axoCover/settings.json 122 | 123 | # Visual Studio code coverage results 124 | *.coverage 125 | *.coveragexml 126 | 127 | # NCrunch 128 | _NCrunch_* 129 | .*crunch*.local.xml 130 | nCrunchTemp_* 131 | 132 | # MightyMoose 133 | *.mm.* 134 | AutoTest.Net/ 135 | 136 | # Web workbench (sass) 137 | .sass-cache/ 138 | 139 | # Installshield output folder 140 | [Ee]xpress/ 141 | 142 | # DocProject is a documentation generator add-in 143 | DocProject/buildhelp/ 144 | DocProject/Help/*.HxT 145 | DocProject/Help/*.HxC 146 | DocProject/Help/*.hhc 147 | DocProject/Help/*.hhk 148 | DocProject/Help/*.hhp 149 | DocProject/Help/Html2 150 | DocProject/Help/html 151 | 152 | # Click-Once directory 153 | publish/ 154 | 155 | # Publish Web Output 156 | *.[Pp]ublish.xml 157 | *.azurePubxml 158 | # Note: Comment the next line if you want to checkin your web deploy settings, 159 | # but database connection strings (with potential passwords) will be unencrypted 160 | *.pubxml 161 | *.publishproj 162 | 163 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 164 | # checkin your Azure Web App publish settings, but sensitive information contained 165 | # in these scripts will be unencrypted 166 | PublishScripts/ 167 | 168 | # NuGet Packages 169 | *.nupkg 170 | # The packages folder can be ignored because of Package Restore 171 | **/packages/* 172 | # except build/, which is used as an MSBuild target. 173 | !**/packages/build/ 174 | # Uncomment if necessary however generally it will be regenerated when needed 175 | #!**/packages/repositories.config 176 | # NuGet v3's project.json files produces more ignorable files 177 | *.nuget.props 178 | *.nuget.targets 179 | 180 | # Microsoft Azure Build Output 181 | csx/ 182 | *.build.csdef 183 | 184 | # Microsoft Azure Emulator 185 | ecf/ 186 | rcf/ 187 | 188 | # Windows Store app package directories and files 189 | AppPackages/ 190 | BundleArtifacts/ 191 | Package.StoreAssociation.xml 192 | _pkginfo.txt 193 | *.appx 194 | 195 | # Visual Studio cache files 196 | # files ending in .cache can be ignored 197 | *.[Cc]ache 198 | # but keep track of directories ending in .cache 199 | !*.[Cc]ache/ 200 | 201 | # Others 202 | ClientBin/ 203 | ~$* 204 | *~ 205 | *.dbmdl 206 | *.dbproj.schemaview 207 | *.jfm 208 | *.pfx 209 | *.publishsettings 210 | orleans.codegen.cs 211 | 212 | # Since there are multiple workflows, uncomment next line to ignore bower_components 213 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 214 | #bower_components/ 215 | 216 | # RIA/Silverlight projects 217 | Generated_Code/ 218 | 219 | # Backup & report files from converting an old project file 220 | # to a newer Visual Studio version. Backup files are not needed, 221 | # because we have git ;-) 222 | _UpgradeReport_Files/ 223 | Backup*/ 224 | UpgradeLog*.XML 225 | UpgradeLog*.htm 226 | 227 | # SQL Server files 228 | *.mdf 229 | *.ldf 230 | *.ndf 231 | 232 | # Business Intelligence projects 233 | *.rdl.data 234 | *.bim.layout 235 | *.bim_*.settings 236 | 237 | # Microsoft Fakes 238 | FakesAssemblies/ 239 | 240 | # GhostDoc plugin setting file 241 | *.GhostDoc.xml 242 | 243 | # Node.js Tools for Visual Studio 244 | .ntvs_analysis.dat 245 | node_modules/ 246 | 247 | # Typescript v1 declaration files 248 | typings/ 249 | 250 | # Visual Studio 6 build log 251 | *.plg 252 | 253 | # Visual Studio 6 workspace options file 254 | *.opt 255 | 256 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 257 | *.vbw 258 | 259 | # Visual Studio LightSwitch build output 260 | **/*.HTMLClient/GeneratedArtifacts 261 | **/*.DesktopClient/GeneratedArtifacts 262 | **/*.DesktopClient/ModelManifest.xml 263 | **/*.Server/GeneratedArtifacts 264 | **/*.Server/ModelManifest.xml 265 | _Pvt_Extensions 266 | 267 | # Paket dependency manager 268 | .paket/paket.exe 269 | paket-files/ 270 | 271 | # FAKE - F# Make 272 | .fake/ 273 | 274 | # JetBrains Rider 275 | .idea/ 276 | *.sln.iml 277 | 278 | # CodeRush 279 | .cr/ 280 | 281 | # Python Tools for Visual Studio (PTVS) 282 | __pycache__/ 283 | *.pyc 284 | 285 | # Cake - Uncomment if you are using it 286 | # tools/** 287 | # !tools/packages.config 288 | 289 | # Tabs Studio 290 | *.tss 291 | 292 | # Telerik's JustMock configuration file 293 | *.jmconfig 294 | 295 | # BizTalk build output 296 | *.btp.cs 297 | *.btm.cs 298 | *.odx.cs 299 | *.xsd.cs 300 | ApprovalBot/PrivateSettings.config 301 | demo/ 302 | ApprovalBot/PrivateSettings.local.config 303 | ApprovalBot/PrivateSettings.azure.config 304 | -------------------------------------------------------------------------------- /ApprovalBot.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27428.2037 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApprovalBot", "ApprovalBot\ApprovalBot.csproj", "{4E426B56-D589-44EA-B7A1-72ED5343B354}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {4E426B56-D589-44EA-B7A1-72ED5343B354}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {4E426B56-D589-44EA-B7A1-72ED5343B354}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {4E426B56-D589-44EA-B7A1-72ED5343B354}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {4E426B56-D589-44EA-B7A1-72ED5343B354}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {2013BD2B-0FBF-4CE3-AF48-A537C5836238} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /ApprovalBot/App_Start/WebApiConfig.cs: -------------------------------------------------------------------------------- 1 | using System.Web.Http; 2 | 3 | namespace ApprovalBot 4 | { 5 | public static class WebApiConfig 6 | { 7 | public static void Register(HttpConfiguration config) 8 | { 9 | // Json settings 10 | //config.Formatters.JsonFormatter.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; 11 | //config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); 12 | //config.Formatters.JsonFormatter.SerializerSettings.Formatting = Formatting.Indented; 13 | //JsonConvert.DefaultSettings = () => new JsonSerializerSettings() 14 | //{ 15 | // ContractResolver = new CamelCasePropertyNamesContractResolver(), 16 | // Formatting = Newtonsoft.Json.Formatting.Indented, 17 | // NullValueHandling = NullValueHandling.Ignore, 18 | //}; 19 | 20 | // Web API configuration and services 21 | 22 | // Web API routes 23 | config.MapHttpAttributeRoutes(); 24 | 25 | config.Routes.MapHttpRoute( 26 | name: "DefaultApi", 27 | routeTemplate: "api/{controller}/{id}", 28 | defaults: new { id = RouteParameter.Optional } 29 | ); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ApprovalBot/ApprovalBot.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | 8 | 9 | 2.0 10 | {4E426B56-D589-44EA-B7A1-72ED5343B354} 11 | {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} 12 | Library 13 | Properties 14 | ApprovalBot 15 | Bot Application 16 | v4.6 17 | true 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | true 30 | full 31 | false 32 | bin\ 33 | DEBUG;TRACE 34 | prompt 35 | 4 36 | 37 | 38 | pdbonly 39 | true 40 | bin\ 41 | TRACE 42 | prompt 43 | 4 44 | 45 | 46 | 47 | ..\packages\AdaptiveCards.1.0.0\lib\net452\AdaptiveCards.dll 48 | 49 | 50 | ..\packages\Autofac.4.5.0\lib\net45\Autofac.dll 51 | 52 | 53 | $(SolutionDir)\packages\Chronic.Signed.0.3.2\lib\net40\Chronic.dll 54 | 55 | 56 | ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.dll 57 | 58 | 59 | ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.SqlServer.dll 60 | 61 | 62 | ..\packages\Microsoft.Azure.DocumentDB.1.22.0\lib\net45\Microsoft.Azure.Documents.Client.dll 63 | 64 | 65 | ..\packages\Microsoft.Azure.KeyVault.Core.1.0.0\lib\net40\Microsoft.Azure.KeyVault.Core.dll 66 | 67 | 68 | ..\packages\Microsoft.Bot.Builder.3.20.1\lib\net46\Microsoft.Bot.Builder.dll 69 | 70 | 71 | ..\packages\Microsoft.Bot.Builder.3.20.1\lib\net46\Microsoft.Bot.Builder.Autofac.dll 72 | 73 | 74 | ..\packages\Microsoft.Bot.Builder.Azure.3.16.3.40383\lib\net46\Microsoft.Bot.Builder.Azure.dll 75 | 76 | 77 | ..\packages\Microsoft.Bot.Builder.History.3.16.1.38846\lib\net46\Microsoft.Bot.Builder.History.dll 78 | 79 | 80 | ..\packages\Microsoft.Bot.Connector.3.20.1\lib\net46\Microsoft.Bot.Connector.dll 81 | 82 | 83 | 84 | ..\packages\Microsoft.Data.Edm.5.7.0\lib\net40\Microsoft.Data.Edm.dll 85 | 86 | 87 | ..\packages\Microsoft.Data.OData.5.7.0\lib\net40\Microsoft.Data.OData.dll 88 | 89 | 90 | ..\packages\Microsoft.Data.Services.Client.5.7.0\lib\net40\Microsoft.Data.Services.Client.dll 91 | 92 | 93 | ..\packages\Microsoft.Graph.1.12.0\lib\net45\Microsoft.Graph.dll 94 | 95 | 96 | ..\packages\Microsoft.Graph.Core.1.12.0\lib\net45\Microsoft.Graph.Core.dll 97 | 98 | 99 | ..\packages\Microsoft.IdentityModel.Clients.ActiveDirectory.4.4.0\lib\net45\Microsoft.IdentityModel.Clients.ActiveDirectory.dll 100 | 101 | 102 | ..\packages\Microsoft.IdentityModel.Logging.5.2.1\lib\net451\Microsoft.IdentityModel.Logging.dll 103 | 104 | 105 | $(SolutionDir)\packages\Microsoft.IdentityModel.Protocol.Extensions.1.0.4.403061554\lib\net45\Microsoft.IdentityModel.Protocol.Extensions.dll 106 | 107 | 108 | ..\packages\Microsoft.IdentityModel.Protocols.5.2.1\lib\net451\Microsoft.IdentityModel.Protocols.dll 109 | 110 | 111 | ..\packages\Microsoft.IdentityModel.Protocols.OpenIdConnect.5.2.1\lib\net451\Microsoft.IdentityModel.Protocols.OpenIdConnect.dll 112 | 113 | 114 | ..\packages\Microsoft.IdentityModel.Tokens.5.2.1\lib\net451\Microsoft.IdentityModel.Tokens.dll 115 | 116 | 117 | ..\packages\Microsoft.O365.ActionableMessages.Utilities.2.0.1\lib\net452\Microsoft.O365.ActionableMessages.Utilities.dll 118 | 119 | 120 | ..\packages\Microsoft.Owin.4.0.0\lib\net451\Microsoft.Owin.dll 121 | 122 | 123 | ..\packages\Microsoft.Owin.Security.4.0.0\lib\net451\Microsoft.Owin.Security.dll 124 | 125 | 126 | ..\packages\Microsoft.Owin.Security.OpenIdConnect.4.0.0\lib\net451\Microsoft.Owin.Security.OpenIdConnect.dll 127 | 128 | 129 | ..\packages\Microsoft.Rest.ClientRuntime.2.3.8\lib\net452\Microsoft.Rest.ClientRuntime.dll 130 | 131 | 132 | $(SolutionDir)\packages\Microsoft.WindowsAzure.ConfigurationManager.3.1.0\lib\net40\Microsoft.WindowsAzure.Configuration.dll 133 | 134 | 135 | ..\packages\WindowsAzure.Storage.7.2.1\lib\net40\Microsoft.WindowsAzure.Storage.dll 136 | 137 | 138 | ..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll 139 | 140 | 141 | ..\packages\Owin.1.0\lib\net40\Owin.dll 142 | 143 | 144 | 145 | 146 | ..\packages\System.IdentityModel.Tokens.Jwt.5.2.1\lib\net451\System.IdentityModel.Tokens.Jwt.dll 147 | 148 | 149 | 150 | ..\packages\System.Net.Http.4.3.1\lib\net46\System.Net.Http.dll 151 | 152 | 153 | $(SolutionDir)\packages\Microsoft.AspNet.WebApi.Client.5.2.3\lib\net45\System.Net.Http.Formatting.dll 154 | 155 | 156 | 157 | 158 | ..\packages\System.Security.Cryptography.Algorithms.4.3.0\lib\net46\System.Security.Cryptography.Algorithms.dll 159 | True 160 | 161 | 162 | ..\packages\System.Security.Cryptography.Encoding.4.3.0\lib\net46\System.Security.Cryptography.Encoding.dll 163 | 164 | 165 | ..\packages\System.Security.Cryptography.Primitives.4.3.0\lib\net46\System.Security.Cryptography.Primitives.dll 166 | 167 | 168 | ..\packages\System.Security.Cryptography.X509Certificates.4.3.0\lib\net46\System.Security.Cryptography.X509Certificates.dll 169 | True 170 | 171 | 172 | ..\packages\System.Spatial.5.7.0\lib\net40\System.Spatial.dll 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | $(SolutionDir)\packages\Microsoft.AspNet.WebApi.Core.5.2.3\lib\net45\System.Web.Http.dll 185 | 186 | 187 | $(SolutionDir)\packages\Microsoft.AspNet.WebApi.WebHost.5.2.3\lib\net45\System.Web.Http.WebHost.dll 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | Designer 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | Global.asax 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | Designer 231 | 232 | 233 | Designer 234 | 235 | 236 | 237 | 238 | Web.config 239 | 240 | 241 | Web.config 242 | 243 | 244 | 245 | 10.0 246 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 247 | 248 | 249 | true 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | True 259 | True 260 | 3979 261 | / 262 | http://localhost:3979/ 263 | False 264 | False 265 | 266 | 267 | False 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. 276 | 277 | 278 | 279 | 286 | -------------------------------------------------------------------------------- /ApprovalBot/Controllers/MessagesController.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http; 3 | using System.Threading.Tasks; 4 | using System.Web.Http; 5 | using Microsoft.Bot.Builder.Dialogs; 6 | using Microsoft.Bot.Connector; 7 | 8 | namespace ApprovalBot 9 | { 10 | [BotAuthentication] 11 | public class MessagesController : ApiController 12 | { 13 | /// 14 | /// POST: api/Messages 15 | /// Receive a message from a user and reply to it 16 | /// 17 | public async Task Post([FromBody]Activity activity) 18 | { 19 | if (activity.Type == ActivityTypes.Message) 20 | { 21 | await Conversation.SendAsync(activity, () => new Dialogs.ExceptionHandlerDialog(new Dialogs.RootDialog())); 22 | } 23 | else 24 | { 25 | await HandleSystemMessage(activity); 26 | } 27 | var response = Request.CreateResponse(HttpStatusCode.OK); 28 | return response; 29 | } 30 | 31 | private async Task HandleSystemMessage(Activity message) 32 | { 33 | if (message.Type == ActivityTypes.DeleteUserData) 34 | { 35 | // Implement user deletion here 36 | // If we handle user deletion, return a real message 37 | } 38 | else if (message.Type == ActivityTypes.ConversationUpdate) 39 | { 40 | // Handle conversation state changes, like members being added and removed 41 | // Use Activity.MembersAdded and Activity.MembersRemoved and Activity.Action for info 42 | // Not available in all channels 43 | } 44 | else if (message.Type == ActivityTypes.ContactRelationUpdate) 45 | { 46 | // Handle add/remove from contact lists 47 | // Activity.From + Activity.Action represent what happened 48 | } 49 | else if (message.Type == ActivityTypes.Typing) 50 | { 51 | // Handle knowing that the user is typing 52 | } 53 | else if (message.Type == ActivityTypes.Event) 54 | { 55 | // Send TokenResponse Events along to the Dialog stack 56 | if (message.IsTokenResponseEvent()) 57 | { 58 | await Conversation.SendAsync(message, () => new Dialogs.RootDialog()); 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /ApprovalBot/Controllers/ResponsesController.cs: -------------------------------------------------------------------------------- 1 | using AdaptiveCards; 2 | using ApprovalBot.Helpers; 3 | using ApprovalBot.Models; 4 | using Microsoft.O365.ActionableMessages.Utilities; 5 | using System; 6 | using System.Configuration; 7 | using System.Net; 8 | using System.Net.Http; 9 | using System.Net.Http.Headers; 10 | using System.Threading.Tasks; 11 | using System.Web.Http; 12 | 13 | namespace ApprovalBot.Controllers 14 | { 15 | public class ResponsesController : ApiController 16 | { 17 | private readonly string baseUrl = ConfigurationManager.AppSettings["AppRootUrl"]; 18 | private readonly string amSender = ConfigurationManager.AppSettings["SenderEmail"]; 19 | private readonly string ngrokUrl = ConfigurationManager.AppSettings["NgrokRootUrl"]; 20 | public async Task PostResponse(ActionableEmailResponse response) 21 | { 22 | // Validate the authorization header 23 | bool isTokenValid = await ValidateAuthorizationHeader(Request.Headers.Authorization, 24 | string.IsNullOrEmpty(ngrokUrl) ? baseUrl : ngrokUrl, response.UserEmail); 25 | if (!isTokenValid) 26 | { 27 | return Unauthorized(); 28 | } 29 | 30 | // Get the approval request 31 | var approval = await DatabaseHelper.GetApprovalAsync(response.ApprovalId); 32 | if (approval == null) 33 | { 34 | return BadRequest("Invalid approval ID"); 35 | } 36 | 37 | // Find the user in approvers 38 | bool userIsAnApprover = false; 39 | foreach(var approver in approval.Approvers) 40 | { 41 | if (approver.EmailAddress == response.UserEmail) 42 | { 43 | userIsAnApprover = true; 44 | approver.Response = response.Response == "approved" ? ResponseStatus.Approved : ResponseStatus.Rejected; 45 | approver.ResponseNote = response.Notes; 46 | } 47 | } 48 | 49 | if (userIsAnApprover) 50 | { 51 | // Update database 52 | await DatabaseHelper.UpdateApprovalAsync(approval.Id, approval); 53 | return GenerateResponseCard(approval); 54 | } 55 | 56 | return BadRequest("User is not an approver"); 57 | } 58 | 59 | private IHttpActionResult GenerateResponseCard(Approval approval) 60 | { 61 | var responseCard = ApprovalStatusHelper.GetApprovalStatusCard(approval); 62 | 63 | // Modify card for this use case 64 | responseCard.Body.Insert(0, new AdaptiveTextBlock() 65 | { 66 | Text = $"Response status as of {TimeZoneHelper.GetAdaptiveDateTimeString(DateTimeOffset.UtcNow)}:" 67 | }); 68 | 69 | responseCard.Body.Insert(0, new AdaptiveTextBlock() 70 | { 71 | Text = "Thanks for responding. Your response has been recorded." 72 | }); 73 | 74 | HttpResponseMessage refreshCardResponse = new HttpResponseMessage(HttpStatusCode.OK); 75 | refreshCardResponse.Headers.Add("CARD-UPDATE-IN-BODY", "true"); 76 | 77 | refreshCardResponse.Content = new StringContent(responseCard.ToJson(), System.Text.Encoding.UTF8, "application/json"); 78 | return ResponseMessage(refreshCardResponse); 79 | } 80 | 81 | private async Task ValidateAuthorizationHeader(AuthenticationHeaderValue authHeader, string targetUrl, string userId) 82 | { 83 | // Validate that we have a bearer token 84 | if (authHeader == null || 85 | !string.Equals(authHeader.Scheme, "bearer", StringComparison.OrdinalIgnoreCase) || 86 | string.IsNullOrEmpty(authHeader.Parameter)) 87 | { 88 | return false; 89 | } 90 | 91 | // Validate the token 92 | ActionableMessageTokenValidator validator = new ActionableMessageTokenValidator(); 93 | ActionableMessageTokenValidationResult result = await validator.ValidateTokenAsync(authHeader.Parameter, targetUrl); 94 | if (!result.ValidationSucceeded) 95 | { 96 | return false; 97 | } 98 | 99 | // Token is valid, now check the sender and action performer 100 | // Both should equal the user 101 | if (!string.Equals(result.ActionPerformer, userId, StringComparison.OrdinalIgnoreCase) || 102 | !string.Equals(result.Sender, string.IsNullOrEmpty(amSender) ? userId : amSender, StringComparison.OrdinalIgnoreCase)) 103 | { 104 | return false; 105 | } 106 | 107 | return true; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /ApprovalBot/Dialogs/ExceptionHandlerDialog.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Bot.Builder.Dialogs; 2 | using System; 3 | using System.Configuration; 4 | using System.Threading.Tasks; 5 | 6 | namespace ApprovalBot.Dialogs 7 | { 8 | [Serializable] 9 | public class ExceptionHandlerDialog : IDialog 10 | { 11 | private readonly IDialog dialog; 12 | private readonly bool displayException = 13 | string.IsNullOrEmpty(ConfigurationManager.AppSettings["DisplayExceptions"]) ? false : 14 | Convert.ToBoolean(ConfigurationManager.AppSettings["DisplayExceptions"]); 15 | 16 | public ExceptionHandlerDialog(IDialog dialog) 17 | { 18 | this.dialog = dialog; 19 | } 20 | 21 | public async Task StartAsync(IDialogContext context) 22 | { 23 | try 24 | { 25 | context.Call(dialog, ResumeAsync); 26 | } 27 | catch (Exception ex) 28 | { 29 | await LogException(ex); 30 | 31 | if (displayException) 32 | { 33 | await DisplayException(context, ex).ConfigureAwait(false); 34 | } 35 | } 36 | } 37 | 38 | public async Task ResumeAsync(IDialogContext context, IAwaitable result) 39 | { 40 | try 41 | { 42 | context.Done(await result); 43 | } 44 | catch (Exception ex) 45 | { 46 | await LogException(ex); 47 | 48 | if (displayException) 49 | { 50 | await DisplayException(context, ex).ConfigureAwait(false); 51 | } 52 | } 53 | } 54 | 55 | private async Task DisplayException(IDialogContext context, Exception ex) 56 | { 57 | var message = ex.Message.Replace(Environment.NewLine, "** \n**"); 58 | var stack = ex.StackTrace.Replace(Environment.NewLine, " \n"); 59 | 60 | await context.PostAsync($"**{message}** \n\n{stack}").ConfigureAwait(false); 61 | } 62 | 63 | private async Task LogException(Exception ex) 64 | { 65 | var exceptionLog = new Helpers.GraphLogEntry(ex, string.Empty); 66 | await Helpers.DatabaseHelper.AddGraphLog(exceptionLog); 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /ApprovalBot/Dialogs/RootDialog.cs: -------------------------------------------------------------------------------- 1 | using AdaptiveCards; 2 | using ApprovalBot.Helpers; 3 | using ApprovalBot.Models; 4 | using Microsoft.Bot.Builder.Dialogs; 5 | using Microsoft.Bot.Connector; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Configuration; 9 | using System.Threading.Tasks; 10 | 11 | namespace ApprovalBot.Dialogs 12 | { 13 | [Serializable] 14 | public class RootDialog : IDialog 15 | { 16 | private static string ConnectionName = ConfigurationManager.AppSettings["ConnectionName"]; 17 | 18 | public async Task StartAsync(IDialogContext context) 19 | { 20 | context.Wait(MessageReceivedAsync); 21 | } 22 | 23 | private async Task MessageReceivedAsync(IDialogContext context, IAwaitable result) 24 | { 25 | var activity = await result as Activity; 26 | string userText = string.IsNullOrEmpty(activity.Text) ? string.Empty : activity.Text.ToLower(); 27 | 28 | // Handle the text as appropriate 29 | if (IsMessageEmpty(activity) || userText.StartsWith("help")) 30 | { 31 | await ShowHelp(context); 32 | context.Wait(MessageReceivedAsync); 33 | } 34 | 35 | else if (userText.StartsWith("hi") || userText.StartsWith("hello") || userText.StartsWith("hey")) 36 | { 37 | await context.PostAsync(@"Hi! What can I do for you? (try ""get approval"" to start an approval request)"); 38 | context.Wait(MessageReceivedAsync); 39 | } 40 | 41 | else if (userText.StartsWith("logout")) 42 | { 43 | await context.SignOutUserAsync(ConnectionName); 44 | await context.PostAsync("You are now logged out."); 45 | context.Wait(MessageReceivedAsync); 46 | } 47 | 48 | else if (userText.StartsWith("reset demo please")) 49 | { 50 | await ResetDemo(context, activity); 51 | await context.PostAsync("DEMO RESET"); 52 | context.Wait(MessageReceivedAsync); 53 | } 54 | 55 | // Anything else requires auth 56 | // The text can be empty when receiving a message back from a card 57 | // button. 58 | else if (string.IsNullOrEmpty(userText)) 59 | { 60 | // Handle card action data 61 | var actionData = ActionData.Parse(activity.Value); 62 | 63 | // When the user selects a file in the select file card 64 | if (actionData.CardAction == CardActionTypes.SelectFile) 65 | { 66 | // Save selected file to conversation state 67 | context.ConversationData.SetValue("selectedFile", actionData.SelectedFile); 68 | // Show file detail card and confirm selection 69 | context.Call(CreateGetTokenDialog(), ConfirmFile); 70 | //reply = await ConfirmFile(context, activity, accessToken.Token, actionData.SelectedFile); 71 | } 72 | // When the user clicks the "send approval request" button 73 | else if (actionData.CardAction == CardActionTypes.SendApprovalRequest) 74 | { 75 | // Check approvers 76 | if (string.IsNullOrEmpty(actionData.Approvers)) 77 | { 78 | SaveMissingInfoState(context, "approvers", actionData); 79 | await context.PostAsync("I need at least one approver email address to send to. Who should I send to?"); 80 | context.Wait(MessageReceivedAsync); 81 | } 82 | 83 | else 84 | { 85 | string[] approvers = EmailHelper.ConvertDelimitedAddressStringToArray(actionData.Approvers.Trim()); 86 | if (approvers == null) 87 | { 88 | SaveMissingInfoState(context, "approvers", actionData); 89 | await context.PostAsync($"One or more values in **{actionData.Approvers}** is not a valid SMTP email address. Can you give me the list of approvers again?"); 90 | context.Wait(MessageReceivedAsync); 91 | } 92 | else 93 | { 94 | //await ShowTyping(context, activity); 95 | // Save user ID, selected file, and approvers to conversation state 96 | context.ConversationData.SetValue("approvalRequestor", activity.From.Id); 97 | context.ConversationData.SetValue("selectedFile", actionData.SelectedFile); 98 | context.ConversationData.SetValue("approvers", approvers); 99 | 100 | context.Call(CreateGetTokenDialog(), SendApprovalRequest); 101 | } 102 | } 103 | } 104 | // User clicked the "no" button when confirming the file. 105 | else if (actionData.CardAction == CardActionTypes.WrongFile) 106 | { 107 | // Re-prompt for a file 108 | context.Call(CreateGetTokenDialog(), PromptForFile); 109 | } 110 | // User selected a pending approval to check status 111 | else if (actionData.CardAction == CardActionTypes.SelectApproval) 112 | { 113 | // Save the selected approval to conversation state 114 | context.ConversationData.SetValue("selectedApproval", actionData.SelectedApproval); 115 | context.Call(CreateGetTokenDialog(), GetApprovalStatus); 116 | } 117 | else 118 | { 119 | await context.PostAsync(@"I'm sorry, I don't understand what you want me to do. Type ""help"" to see a list of things I can do."); 120 | context.Wait(MessageReceivedAsync); 121 | } 122 | } 123 | else if (userText.StartsWith("get approval")) 124 | { 125 | RemoveMissingInfoState(context); 126 | context.Call(CreateGetTokenDialog(), PromptForFile); 127 | } 128 | else if (userText.StartsWith("check status")) 129 | { 130 | RemoveMissingInfoState(context); 131 | // Save user ID to conversation state 132 | context.ConversationData.SetValue("approvalRequestor", activity.From.Id); 133 | context.Call(CreateGetTokenDialog(), PromptForApprovalRequest); 134 | } 135 | else if (!string.IsNullOrEmpty(ExpectedMissingInfo(context))) 136 | { 137 | string missingField = ExpectedMissingInfo(context); 138 | 139 | if (missingField == "approvers") 140 | { 141 | // Validate input 142 | string[] approvers = EmailHelper.ConvertDelimitedAddressStringToArray(userText.Trim()); 143 | if (approvers == null) 144 | { 145 | await context.PostAsync(@"Sorry, I'm still having trouble. Please enter the approvers again, keeping in mind: 146 | - Use full SMTP email addresses, like `bob@contsoso.com` 147 | - Separate multiple email addresses with a semicolon (`;`), like `bob@contoso.com;allie@contoso.com`"); 148 | } 149 | else 150 | { 151 | ActionData actionData = context.ConversationData.GetValue("actionData"); 152 | RemoveMissingInfoState(context); 153 | //await ShowTyping(context, activity); 154 | 155 | // Save user ID, selected file, and approvers to conversation state 156 | context.ConversationData.SetValue("approvalRequestor", activity.From.Id); 157 | context.ConversationData.SetValue("selectedFile", actionData.SelectedFile); 158 | context.ConversationData.SetValue("approvers", approvers); 159 | 160 | context.Call(CreateGetTokenDialog(), SendApprovalRequest); 161 | } 162 | } 163 | } 164 | else 165 | { 166 | await context.PostAsync(@"I'm sorry, I don't understand what you want me to do. Type ""help"" to see a list of things I can do."); 167 | context.Wait(MessageReceivedAsync); 168 | } 169 | } 170 | 171 | private bool IsMessageEmpty(Activity activity) 172 | { 173 | return string.IsNullOrWhiteSpace(activity.Text) && activity.Value == null; 174 | } 175 | 176 | private void SaveMissingInfoState(IDialogContext context, string field, ActionData actionData) 177 | { 178 | context.ConversationData.SetValue("missingField", field); 179 | context.ConversationData.SetValue("actionData", actionData); 180 | } 181 | 182 | private string ExpectedMissingInfo(IDialogContext context) 183 | { 184 | return context.ConversationData.GetValueOrDefault("missingField"); 185 | } 186 | 187 | private void RemoveMissingInfoState(IDialogContext context) 188 | { 189 | context.ConversationData.RemoveValue("missingField"); 190 | context.ConversationData.RemoveValue("actionData"); 191 | } 192 | 193 | private void ClearConversationData(IDialogContext context, params object[] values) 194 | { 195 | foreach (string value in values) 196 | { 197 | context.ConversationData.RemoveValue(value); 198 | } 199 | } 200 | 201 | private async Task PromptForFile(IDialogContext context, IAwaitable tokenResponse) 202 | { 203 | //await ShowTyping(context, activity); 204 | var accessToken = await tokenResponse; 205 | 206 | if (accessToken == null || string.IsNullOrEmpty(accessToken.Token)) 207 | { 208 | await context.PostAsync(Apologize("I could not get an access token")); 209 | return; 210 | } 211 | 212 | // Get a list of files to choose from 213 | AdaptiveCard pickerCard = null; 214 | 215 | try 216 | { 217 | pickerCard = await GraphHelper.GetFilePickerCardFromOneDrive(accessToken.Token); 218 | } 219 | catch (Microsoft.Graph.ServiceException ex) 220 | { 221 | if (ex.Error.Code == "UnknownError" && ex.Message.Contains("Invalid Hostname")) 222 | { 223 | // Log that this happened 224 | await DatabaseHelper.AddGraphLog(new GraphLogEntry(ex, "retry-get-file-picker")); 225 | // retry 226 | pickerCard = await GraphHelper.GetFilePickerCardFromOneDrive(accessToken.Token); 227 | } 228 | else 229 | { 230 | throw; 231 | } 232 | } 233 | 234 | if (pickerCard != null) 235 | { 236 | var reply = context.MakeMessage(); 237 | reply.Attachments = new List() 238 | { 239 | new Attachment() { ContentType = AdaptiveCard.ContentType, Content = pickerCard } 240 | }; 241 | 242 | await context.PostAsync(reply); 243 | } 244 | else 245 | { 246 | await context.PostAsync("I couldn't find any files in your OneDrive. Please add the file you want approved and try again."); 247 | } 248 | } 249 | 250 | private async Task ConfirmFile(IDialogContext context, IAwaitable tokenResponse) 251 | { 252 | var accessToken = await tokenResponse; 253 | var fileId = context.ConversationData.GetValueOrDefault("selectedFile"); 254 | 255 | ClearConversationData(context, "selectedFile"); 256 | 257 | if (accessToken == null || string.IsNullOrEmpty(accessToken.Token)) 258 | { 259 | await context.PostAsync(Apologize("I could not get an access token")); 260 | return; 261 | } 262 | 263 | if (string.IsNullOrEmpty(fileId)) 264 | { 265 | await context.PostAsync(Apologize("I could not find a selected file ID in the conversation state")); 266 | return; 267 | } 268 | 269 | //await ShowTyping(context, activity); 270 | var reply = context.MakeMessage(); 271 | AdaptiveCard fileDetailCard = null; 272 | 273 | try 274 | { 275 | fileDetailCard = await GraphHelper.GetFileDetailCard(accessToken.Token, fileId); 276 | } 277 | catch (Microsoft.Graph.ServiceException ex) 278 | { 279 | if (ex.Error.Code == "UnknownError" && ex.Message.Contains("Invalid Hostname")) 280 | { 281 | // Log that this happened 282 | await DatabaseHelper.AddGraphLog(new GraphLogEntry(ex, "retry-get-file-detail")); 283 | // retry 284 | fileDetailCard = await GraphHelper.GetFileDetailCard(accessToken.Token, fileId); 285 | } 286 | else 287 | { 288 | throw; 289 | } 290 | } 291 | 292 | reply.Attachments = new List() 293 | { 294 | new Attachment() { ContentType = AdaptiveCard.ContentType, Content = fileDetailCard } 295 | }; 296 | 297 | await context.PostAsync(reply); 298 | } 299 | 300 | private async Task PromptForApprovalRequest(IDialogContext context, IAwaitable tokenResponse) 301 | { 302 | //await ShowTyping(context, activity); 303 | var accessToken = await tokenResponse; 304 | var userId = context.ConversationData.GetValueOrDefault("approvalRequestor"); 305 | 306 | ClearConversationData(context, "approvalRequestor"); 307 | 308 | if (accessToken == null || string.IsNullOrEmpty(accessToken.Token)) 309 | { 310 | await context.PostAsync(Apologize("I could not get an access token")); 311 | return; 312 | } 313 | 314 | if (string.IsNullOrEmpty(userId)) 315 | { 316 | await context.PostAsync(Apologize("I could not find a user ID in the conversation state")); 317 | return; 318 | } 319 | 320 | var statusCardList = await ApprovalStatusHelper.GetApprovalsForUserCard(accessToken.Token, userId); 321 | if (statusCardList == null) 322 | { 323 | await context.PostAsync("I'm sorry, but I didn't find any approvals requested by you."); 324 | } 325 | else 326 | { 327 | var reply = context.MakeMessage(); 328 | 329 | if (statusCardList.Count > 1) 330 | { 331 | reply.Text = "Select an approval to see its status."; 332 | reply.AttachmentLayout = AttachmentLayoutTypes.Carousel; 333 | } 334 | 335 | reply.Attachments = new List(); 336 | 337 | foreach(var card in statusCardList) 338 | { 339 | reply.Attachments.Add(new Attachment() 340 | { 341 | ContentType = AdaptiveCard.ContentType, 342 | Content = card 343 | }); 344 | } 345 | 346 | await context.PostAsync(reply); 347 | } 348 | } 349 | 350 | private async Task SendApprovalRequest(IDialogContext context, IAwaitable tokenResponse) 351 | { 352 | var accessToken = await tokenResponse; 353 | var userId = context.ConversationData.GetValueOrDefault("approvalRequestor"); 354 | var fileId = context.ConversationData.GetValueOrDefault("selectedFile"); 355 | var approvers = context.ConversationData.GetValueOrDefault("approvers"); 356 | 357 | ClearConversationData(context, "approvalRequestor", "selectedFile", "approvers"); 358 | 359 | if (accessToken == null || string.IsNullOrEmpty(accessToken.Token)) 360 | { 361 | await context.PostAsync(Apologize("I could not get an access token")); 362 | return; 363 | } 364 | 365 | if (string.IsNullOrEmpty(userId)) 366 | { 367 | await context.PostAsync(Apologize("I could not find a user ID in the conversation state")); 368 | return; 369 | } 370 | 371 | if (string.IsNullOrEmpty(fileId)) 372 | { 373 | await context.PostAsync(Apologize("I could not find a selected file ID in the conversation state")); 374 | return; 375 | } 376 | 377 | if (string.IsNullOrEmpty(fileId)) 378 | { 379 | await context.PostAsync(Apologize("I could not find list of approvers in the conversation state")); 380 | return; 381 | } 382 | 383 | try 384 | { 385 | await ApprovalRequestHelper.SendApprovalRequest(accessToken.Token, userId, fileId, approvers); 386 | } 387 | catch (Microsoft.Graph.ServiceException ex) 388 | { 389 | if (ex.Error.Code == "UnknownError" && ex.Message.Contains("Invalid Hostname")) 390 | { 391 | // Log that this happened 392 | await DatabaseHelper.AddGraphLog(new GraphLogEntry(ex, "retry-send-approval")); 393 | // retry 394 | await ApprovalRequestHelper.SendApprovalRequest(accessToken.Token, userId, fileId, approvers); 395 | } 396 | else 397 | { 398 | throw; 399 | } 400 | } 401 | 402 | await context.PostAsync(@"I've sent the request. You can check the status of your request by typing ""check status""."); 403 | } 404 | 405 | private async Task GetApprovalStatus(IDialogContext context, IAwaitable tokenResponse) 406 | { 407 | //await ShowTyping(context, activity); 408 | var approvalId = context.ConversationData.GetValueOrDefault("selectedApproval"); 409 | ClearConversationData(context, "selectedApproval"); 410 | 411 | if (string.IsNullOrEmpty(approvalId)) 412 | { 413 | await context.PostAsync(Apologize("I could not find a selected approval ID in the conversation state")); 414 | return; 415 | } 416 | 417 | var statusCard = await ApprovalStatusHelper.GetApprovalStatusCard(approvalId); 418 | 419 | await context.PostAsync("Here's what I found:"); 420 | 421 | var reply = context.MakeMessage(); 422 | reply.Attachments = new List() 423 | { 424 | new Attachment() { ContentType = AdaptiveCard.ContentType, Content = statusCard } 425 | }; 426 | 427 | await context.PostAsync(reply); 428 | } 429 | 430 | private async Task ShowTyping(IDialogContext context, Activity activity) 431 | { 432 | var typing = activity.CreateReply(); 433 | typing.Type = ActivityTypes.Typing; 434 | await context.PostAsync(typing); 435 | } 436 | 437 | private async Task ShowHelp(IDialogContext context) 438 | { 439 | await context.PostAsync("I am ApprovalBot. I can help you get public release approval for any documents in your OneDrive for Business."); 440 | await context.PostAsync(@"Try one of the following commands to get started: 441 | 442 | - ""get approval"" 443 | - ""check status"""); 444 | } 445 | 446 | private async Task ResetDemo(IDialogContext context, Activity activity) 447 | { 448 | // Remove any pending approvals 449 | await DatabaseHelper.DeleteAllUserApprovals(activity.From.Id); 450 | 451 | // Sign out 452 | await context.SignOutUserAsync(ConnectionName); 453 | 454 | context.UserData.Clear(); 455 | } 456 | 457 | private GetTokenDialog CreateGetTokenDialog() 458 | { 459 | return new GetTokenDialog( 460 | ConnectionName, 461 | $"Please sign in to ApprovalBot to proceed.", 462 | "Sign In", 463 | 2, 464 | "Hmm. Something went wrong, let's try again."); 465 | } 466 | 467 | private string Apologize(string moreInfo) 468 | { 469 | return $"I'm sorry, I seem to have gotten my circuits crossed. Please tell a human that ${moreInfo}."; 470 | } 471 | } 472 | } -------------------------------------------------------------------------------- /ApprovalBot/Global.asax: -------------------------------------------------------------------------------- 1 | <%@ Application Codebehind="Global.asax.cs" Inherits="ApprovalBot.WebApiApplication" Language="C#" %> 2 | -------------------------------------------------------------------------------- /ApprovalBot/Global.asax.cs: -------------------------------------------------------------------------------- 1 | using ApprovalBot.Helpers; 2 | using Autofac; 3 | using Microsoft.Bot.Builder.Azure; 4 | using Microsoft.Bot.Builder.Dialogs; 5 | using Microsoft.Bot.Builder.Dialogs.Internals; 6 | using Microsoft.Bot.Connector; 7 | using System; 8 | using System.Configuration; 9 | using System.Reflection; 10 | using System.Web.Http; 11 | 12 | namespace ApprovalBot 13 | { 14 | public class WebApiApplication : System.Web.HttpApplication 15 | { 16 | protected void Application_Start() 17 | { 18 | GlobalConfiguration.Configure(WebApiConfig.Register); 19 | 20 | Conversation.UpdateContainer( 21 | builder => 22 | { 23 | builder.RegisterModule(new AzureModule(Assembly.GetExecutingAssembly())); 24 | 25 | // This will create a CosmosDB store, suitable for production 26 | // NOTE: Requires an actual CosmosDB instance and configuration in 27 | // PrivateSettings.config 28 | var databaseUri = new Uri(ConfigurationManager.AppSettings["DatabaseUri"]); 29 | var databaseKey = ConfigurationManager.AppSettings["DatabaseKey"]; 30 | var store = new DocumentDbBotDataStore(databaseUri, databaseKey); 31 | 32 | builder.Register(c => store) 33 | .Keyed>(AzureModule.Key_DataStore) 34 | .AsSelf() 35 | .SingleInstance(); 36 | }); 37 | 38 | // Initialize approvals database 39 | DatabaseHelper.Initialize(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ApprovalBot/Helpers/ApprovalRequestHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Configuration; 4 | using System.Threading.Tasks; 5 | using AdaptiveCards; 6 | using ApprovalBot.Models; 7 | using ApprovalBot.OutlookAdaptive; 8 | using Microsoft.Graph; 9 | 10 | namespace ApprovalBot.Helpers 11 | { 12 | public static class ApprovalRequestHelper 13 | { 14 | private static readonly string originatorId = ConfigurationManager.AppSettings["OriginatorId"]; 15 | private static readonly string messageSender = ConfigurationManager.AppSettings["SenderEmail"]; 16 | private static readonly string actionBaseUrl = ConfigurationManager.AppSettings["AppRootUrl"]; 17 | private static readonly string ngrokUrl = ConfigurationManager.AppSettings["NgrokRootUrl"]; 18 | 19 | public static async Task SendApprovalRequest(string accessToken, string userId, string fileId, string[] approvers) 20 | { 21 | // Get file info 22 | var fileInfo = await GraphHelper.GetFileInfo(accessToken, fileId); 23 | 24 | // Info on requestor 25 | var requestor = await GraphHelper.GetUser(accessToken); 26 | 27 | // Requestor pic URI 28 | var requestorPicUri = await GraphHelper.GetUserPhotoDataUri(accessToken, "48x48"); 29 | 30 | // File thumbnail URI 31 | var thumbnailUri = await GraphHelper.GetFileThumbnailDataUri(accessToken, fileInfo.Id); 32 | 33 | // Create an approval to store in the database 34 | var approval = new Approval() 35 | { 36 | Requestor = userId, 37 | File = fileInfo, 38 | Approvers = new List(), 39 | RequestDate = DateTimeOffset.UtcNow 40 | }; 41 | 42 | // Add approvers 43 | foreach (string approver in approvers) 44 | { 45 | approval.Approvers.Add(new ApproverInfo() 46 | { 47 | EmailAddress = approver, 48 | Response = Models.ResponseStatus.NotResponded, 49 | ResponseNote = string.Empty 50 | }); 51 | } 52 | 53 | // Add to database 54 | var dbApproval = await DatabaseHelper.CreateApprovalAsync(approval); 55 | 56 | // Build and send card for each recipient 57 | foreach (string approver in approvers) 58 | { 59 | var approvalRequestCard = BuildRequestCard(requestor, requestorPicUri, 60 | fileInfo, thumbnailUri, approver, dbApproval.Id); 61 | 62 | await GraphHelper.SendRequestCard(accessToken, approvalRequestCard, approver, messageSender); 63 | } 64 | } 65 | 66 | private static AdaptiveCard BuildRequestCard(User requestor, string requestorPicUri, ApprovalFileInfo fileInfo, string thumbnailUri, string recipient, string approvalId) 67 | { 68 | // Build actionable email card 69 | var approvalRequestCard = new AdaptiveCard(); 70 | 71 | // Outlook-specific property on AdaptiveCard 72 | if (!string.IsNullOrEmpty(originatorId)) 73 | { 74 | approvalRequestCard.AdditionalProperties.Add("originator", originatorId); 75 | } 76 | 77 | approvalRequestCard.Body.Add(new AdaptiveTextBlock() 78 | { 79 | Text = "Pending Approval", 80 | Weight = AdaptiveTextWeight.Bolder, 81 | Size = AdaptiveTextSize.Large 82 | }); 83 | 84 | approvalRequestCard.Body.Add(new AdaptiveTextBlock() 85 | { 86 | Text = "Requested by" 87 | }); 88 | 89 | 90 | var requestorColumnSet = new AdaptiveColumnSet(); 91 | 92 | if (!string.IsNullOrEmpty(requestorPicUri)) 93 | { 94 | var requestorPic = new AdaptiveImage() 95 | { 96 | Style = AdaptiveImageStyle.Person, 97 | Url = new Uri(requestorPicUri) 98 | }; 99 | 100 | // Outlook-specific property on Image 101 | requestorPic.AdditionalProperties.Add("pixelWidth", "48"); 102 | 103 | var requestorPicCol = new AdaptiveColumn() 104 | { 105 | Width = AdaptiveColumnWidth.Auto.ToLower() 106 | }; 107 | 108 | requestorPicCol.Items.Add(requestorPic); 109 | 110 | requestorColumnSet.Columns.Add(requestorPicCol); 111 | } 112 | 113 | var requestorNameCol = new AdaptiveColumn() 114 | { 115 | Width = AdaptiveColumnWidth.Stretch.ToLower() 116 | }; 117 | 118 | // Outlook-specific property on Column 119 | requestorNameCol.AdditionalProperties.Add("verticalContentAlignment", "center"); 120 | 121 | requestorNameCol.Items.Add(new AdaptiveTextBlock() 122 | { 123 | Size = AdaptiveTextSize.Medium, 124 | Text = requestor.DisplayName 125 | }); 126 | 127 | requestorNameCol.Items.Add(new AdaptiveTextBlock() 128 | { 129 | IsSubtle = true, 130 | Spacing = AdaptiveSpacing.None, 131 | Text = requestor.Mail 132 | }); 133 | 134 | requestorColumnSet.Columns.Add(requestorNameCol); 135 | 136 | approvalRequestCard.Body.Add(requestorColumnSet); 137 | 138 | // File info 139 | approvalRequestCard.Body.Add(new AdaptiveTextBlock() 140 | { 141 | Text = "File needing approval", 142 | Separator = true 143 | }); 144 | 145 | var fileColumnSet = new AdaptiveColumnSet() 146 | { 147 | SelectAction = new AdaptiveOpenUrlAction() 148 | { 149 | Title = fileInfo.Name, 150 | Url = new Uri(fileInfo.SharingUrl) 151 | } 152 | }; 153 | 154 | if (!string.IsNullOrEmpty(thumbnailUri)) 155 | { 156 | var fileThumb = new AdaptiveImage() 157 | { 158 | Url = new Uri(thumbnailUri) 159 | }; 160 | 161 | // Outlook-specific property on Image 162 | fileThumb.AdditionalProperties.Add("pixelWidth", "48"); 163 | 164 | var fileThumbCol = new AdaptiveColumn() 165 | { 166 | Width = AdaptiveColumnWidth.Auto.ToLower() 167 | }; 168 | 169 | fileThumbCol.Items.Add(fileThumb); 170 | 171 | fileColumnSet.Columns.Add(fileThumbCol); 172 | } 173 | 174 | var fileNameCol = new AdaptiveColumn() 175 | { 176 | Width = AdaptiveColumnWidth.Stretch.ToLower() 177 | }; 178 | 179 | // Outlook-specific property on Column 180 | fileNameCol.AdditionalProperties.Add("verticalContentAlignment", "center"); 181 | 182 | fileNameCol.Items.Add(new AdaptiveTextBlock() 183 | { 184 | Size = AdaptiveTextSize.Medium, 185 | Text = fileInfo.Name 186 | }); 187 | 188 | fileNameCol.Items.Add(new AdaptiveTextBlock() 189 | { 190 | IsSubtle = true, 191 | Spacing = AdaptiveSpacing.None, 192 | Text = "(tap to view)" 193 | }); 194 | 195 | fileColumnSet.Columns.Add(fileNameCol); 196 | 197 | approvalRequestCard.Body.Add(fileColumnSet); 198 | 199 | // Respond button 200 | 201 | // Response form (hiddden initially) 202 | var responseForm = new AdaptiveCard(); 203 | responseForm.AdditionalProperties.Add("style", "emphasis"); 204 | 205 | responseForm.Body.Add(new AdaptiveTextInput() 206 | { 207 | Id = "notes", 208 | IsMultiline = true, 209 | Placeholder = "Enter any notes for the requestor" 210 | }); 211 | 212 | responseForm.Actions.Add(new AdaptiveHttpAction() 213 | { 214 | Title = "Approve", 215 | Method = AdaptiveHttpActionMethod.POST, 216 | Url = new Uri($"{(string.IsNullOrEmpty(ngrokUrl) ? actionBaseUrl : ngrokUrl) }/api/responses"), 217 | Body = $@"{{ ""userEmail"": ""{recipient}"", ""approvalId"": ""{approvalId}"", ""response"": ""approved"", ""notes"": ""{{{{notes.value}}}}"" }}s" 218 | }); 219 | 220 | responseForm.Actions.Add(new AdaptiveHttpAction() 221 | { 222 | Title = "Reject", 223 | Method = AdaptiveHttpActionMethod.POST, 224 | Url = new Uri($"{(string.IsNullOrEmpty(ngrokUrl) ? actionBaseUrl : ngrokUrl)}/api/responses"), 225 | Body = $@"{{ ""userEmail"": ""{recipient}"", ""approvalId"": ""{approvalId}"", ""response"": ""rejected"", ""notes"": ""{{{{notes.value}}}}"" }}" 226 | }); 227 | 228 | approvalRequestCard.Actions.Add(new AdaptiveShowCardAction() { 229 | Title = "Respond", 230 | Card = responseForm 231 | }); 232 | 233 | return approvalRequestCard; 234 | } 235 | } 236 | } -------------------------------------------------------------------------------- /ApprovalBot/Helpers/ApprovalStatusHelper.cs: -------------------------------------------------------------------------------- 1 | using AdaptiveCards; 2 | using ApprovalBot.Models; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | 8 | namespace ApprovalBot.Helpers 9 | { 10 | public class ApprovalStatusHelper 11 | { 12 | public static async Task> GetApprovalsForUserCard(string accessToken, string userId) 13 | { 14 | // Get all approvals requested by current user 15 | var approvals = await DatabaseHelper.GetApprovalsAsync(a => a.Requestor == userId); 16 | 17 | if (approvals.Count() == 0) 18 | { 19 | // Return a simple message 20 | return null; 21 | } 22 | else if (approvals.Count() == 1) 23 | { 24 | // Return status card of the only approval 25 | return new List() { GetApprovalStatusCard(approvals.First()) }; 26 | } 27 | else 28 | { 29 | var approvalCardList = new List(); 30 | 31 | foreach (var approval in approvals) 32 | { 33 | var approvalDetailCard = new AdaptiveCard(); 34 | 35 | var approvalColumnSet = new AdaptiveColumnSet(); 36 | 37 | // Get thumbnail 38 | var thumbnailUri = await GraphHelper.GetFileThumbnailDataUri(accessToken, approval.File.Id); 39 | 40 | var fileThumbCol = new AdaptiveColumn() 41 | { 42 | Width = AdaptiveColumnWidth.Auto.ToLower() 43 | }; 44 | 45 | fileThumbCol.Items.Add(new AdaptiveImage() 46 | { 47 | Url = new Uri(thumbnailUri), 48 | AltText = "File thumbnail", 49 | Size = AdaptiveImageSize.Small 50 | }); 51 | 52 | approvalColumnSet.Columns.Add(fileThumbCol); 53 | 54 | var fileNameCol = new AdaptiveColumn() 55 | { 56 | Width = AdaptiveColumnWidth.Stretch.ToLower() 57 | }; 58 | 59 | fileNameCol.Items.Add(new AdaptiveTextBlock() 60 | { 61 | Size = AdaptiveTextSize.Medium, 62 | Text = approval.File.Name 63 | }); 64 | 65 | fileNameCol.Items.Add(new AdaptiveTextBlock() 66 | { 67 | IsSubtle = true, 68 | Spacing = AdaptiveSpacing.None, 69 | Text = $"Requested: {TimeZoneHelper.GetAdaptiveDateTimeString(approval.RequestDate)}" 70 | }); 71 | 72 | approvalColumnSet.Columns.Add(fileNameCol); 73 | 74 | approvalDetailCard.Body.Add(approvalColumnSet); 75 | 76 | approvalDetailCard.Actions.Add(new AdaptiveSubmitAction() 77 | { 78 | Title = "This one", 79 | DataJson = $@"{{ ""cardAction"": ""{CardActionTypes.SelectApproval}"", ""selectedApproval"": ""{approval.Id}"" }}" 80 | }); 81 | 82 | approvalCardList.Add(approvalDetailCard); 83 | } 84 | 85 | return approvalCardList; 86 | } 87 | } 88 | 89 | public static async Task GetApprovalStatusCard(string approvalId) 90 | { 91 | var approval = await DatabaseHelper.GetApprovalAsync(approvalId); 92 | return GetApprovalStatusCard(approval); 93 | } 94 | 95 | public static AdaptiveCard GetApprovalStatusCard(Approval approval) 96 | { 97 | var statusCard = new AdaptiveCard(); 98 | 99 | statusCard.Body.Add(new AdaptiveTextBlock() 100 | { 101 | Weight = AdaptiveTextWeight.Bolder, 102 | Size = AdaptiveTextSize.Medium, 103 | Text = $"Status for {approval.File.Name}" 104 | }); 105 | 106 | foreach (var approver in approval.Approvers) 107 | { 108 | var approverColumnSet = new AdaptiveColumnSet(); 109 | 110 | var emailAddressColumn = new AdaptiveColumn() 111 | { 112 | Width = AdaptiveColumnWidth.Stretch.ToLower() 113 | }; 114 | 115 | emailAddressColumn.Items.Add(new AdaptiveTextBlock() 116 | { 117 | Text = approver.EmailAddress 118 | }); 119 | 120 | approverColumnSet.Columns.Add(emailAddressColumn); 121 | 122 | var responseColumn = new AdaptiveColumn() 123 | { 124 | Width = AdaptiveColumnWidth.Auto.ToLower() 125 | }; 126 | 127 | responseColumn.Items.Add(ResponseCardTextBlockFromResponse(approver.Response)); 128 | 129 | approverColumnSet.Columns.Add(responseColumn); 130 | 131 | statusCard.Body.Add(approverColumnSet); 132 | 133 | if (!string.IsNullOrEmpty(approver.ResponseNote)) 134 | { 135 | var notesContainer = new AdaptiveContainer() 136 | { 137 | Style = AdaptiveContainerStyle.Emphasis 138 | }; 139 | 140 | notesContainer.Items.Add(new AdaptiveTextBlock() 141 | { 142 | Weight = AdaptiveTextWeight.Bolder, 143 | Text = "Note:" 144 | }); 145 | 146 | notesContainer.Items.Add(new AdaptiveTextBlock() 147 | { 148 | Text = approver.ResponseNote, 149 | Wrap = true 150 | }); 151 | 152 | statusCard.Body.Add(notesContainer); 153 | } 154 | } 155 | 156 | return statusCard; 157 | } 158 | 159 | private static AdaptiveTextBlock ResponseCardTextBlockFromResponse(ResponseStatus response) 160 | { 161 | var textBlock = new AdaptiveTextBlock(); 162 | 163 | switch (response) 164 | { 165 | case ResponseStatus.Approved: 166 | textBlock.Color = AdaptiveTextColor.Good; 167 | textBlock.Text = "Approved"; 168 | break; 169 | case ResponseStatus.Rejected: 170 | textBlock.Color = AdaptiveTextColor.Attention; 171 | textBlock.Text = "Rejected"; 172 | break; 173 | default: 174 | textBlock.Color = AdaptiveTextColor.Warning; 175 | textBlock.Text = "Not responded"; 176 | break; 177 | } 178 | 179 | return textBlock; 180 | } 181 | } 182 | } -------------------------------------------------------------------------------- /ApprovalBot/Helpers/DatabaseHelper.cs: -------------------------------------------------------------------------------- 1 | using ApprovalBot.Models; 2 | using Microsoft.Azure.Documents; 3 | using Microsoft.Azure.Documents.Client; 4 | using Microsoft.Azure.Documents.Linq; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Configuration; 8 | using System.Linq; 9 | using System.Linq.Expressions; 10 | using System.Net; 11 | using System.Threading.Tasks; 12 | 13 | namespace ApprovalBot.Helpers 14 | { 15 | public static class DatabaseHelper 16 | { 17 | private static readonly string databaseUri = ConfigurationManager.AppSettings["DatabaseUri"]; 18 | private static readonly string databaseKey = ConfigurationManager.AppSettings["DatabaseKey"]; 19 | private static readonly string databaseName = "ApprovalBotDB"; 20 | private static readonly string collectionName = "Approvals"; 21 | private static readonly string loggingCollectionName = "GraphRequests"; 22 | 23 | private static DocumentClient client = null; 24 | 25 | public static void Initialize() 26 | { 27 | client = new DocumentClient(new Uri(databaseUri), databaseKey); 28 | CreateDatabaseIfNotExistsAsync().Wait(); 29 | CreateCollectionIfNotExistsAsync().Wait(); 30 | } 31 | 32 | private static async Task CreateDatabaseIfNotExistsAsync() 33 | { 34 | try 35 | { 36 | await client.ReadDatabaseAsync(UriFactory.CreateDatabaseUri(databaseName)); 37 | } 38 | catch (DocumentClientException e) 39 | { 40 | if (e.StatusCode == HttpStatusCode.NotFound) 41 | { 42 | await client.CreateDatabaseAsync(new Database { Id = databaseName }); 43 | } 44 | else 45 | { 46 | throw; 47 | } 48 | } 49 | } 50 | 51 | private static async Task CreateCollectionIfNotExistsAsync() 52 | { 53 | try 54 | { 55 | await client.ReadDocumentCollectionAsync(UriFactory.CreateDocumentCollectionUri(databaseName, collectionName)); 56 | } 57 | catch (DocumentClientException e) 58 | { 59 | if (e.StatusCode == System.Net.HttpStatusCode.NotFound) 60 | { 61 | await client.CreateDocumentCollectionAsync( 62 | UriFactory.CreateDatabaseUri(databaseName), 63 | new DocumentCollection { Id = collectionName }, 64 | new RequestOptions { OfferThroughput = 1000 }); 65 | } 66 | else 67 | { 68 | throw; 69 | } 70 | } 71 | 72 | try 73 | { 74 | await client.ReadDocumentCollectionAsync(UriFactory.CreateDocumentCollectionUri(databaseName, loggingCollectionName)); 75 | } 76 | catch (DocumentClientException e) 77 | { 78 | if (e.StatusCode == System.Net.HttpStatusCode.NotFound) 79 | { 80 | await client.CreateDocumentCollectionAsync( 81 | UriFactory.CreateDatabaseUri(databaseName), 82 | new DocumentCollection { Id = loggingCollectionName }, 83 | new RequestOptions { OfferThroughput = 1000 }); 84 | } 85 | else 86 | { 87 | throw; 88 | } 89 | } 90 | } 91 | 92 | public static async Task> GetApprovalsAsync(Expression> predicate) 93 | { 94 | IDocumentQuery query = client.CreateDocumentQuery( 95 | UriFactory.CreateDocumentCollectionUri(databaseName, collectionName)) 96 | .Where(predicate) 97 | .AsDocumentQuery(); 98 | 99 | List results = new List(); 100 | while (query.HasMoreResults) 101 | { 102 | results.AddRange(await query.ExecuteNextAsync()); 103 | } 104 | 105 | return results; 106 | } 107 | 108 | public static async Task GetApprovalAsync(string id) 109 | { 110 | try 111 | { 112 | Document document = await client.ReadDocumentAsync(UriFactory.CreateDocumentUri(databaseName, collectionName, id)); 113 | return (Approval)(dynamic)document; 114 | } 115 | catch (DocumentClientException e) 116 | { 117 | if (HttpStatusCode.NotFound == e.StatusCode) 118 | { 119 | return null; 120 | } 121 | else 122 | { 123 | throw; 124 | } 125 | } 126 | } 127 | 128 | public static async Task CreateApprovalAsync(Approval approval) 129 | { 130 | Document document = await client.CreateDocumentAsync( 131 | UriFactory.CreateDocumentCollectionUri(databaseName, collectionName), 132 | approval); 133 | 134 | return (Approval)(dynamic)document; 135 | } 136 | 137 | public static async Task UpdateApprovalAsync(string id, Approval approval) 138 | { 139 | Document document = await client.ReplaceDocumentAsync( 140 | UriFactory.CreateDocumentUri(databaseName, collectionName, id), approval); 141 | 142 | return (Approval)(dynamic)document; 143 | } 144 | 145 | public static async Task DeleteApprovalAsync(string id) 146 | { 147 | try 148 | { 149 | await client.DeleteDocumentAsync(UriFactory.CreateDocumentUri(databaseName, collectionName, id)); 150 | } 151 | catch (DocumentClientException e) 152 | { 153 | if (e.StatusCode != HttpStatusCode.NotFound) 154 | { 155 | throw; 156 | } 157 | } 158 | } 159 | 160 | public static async Task DeleteAllUserApprovals(string userId) 161 | { 162 | var userApprovals = await GetApprovalsAsync(a => a.Requestor == userId); 163 | 164 | foreach (var approval in userApprovals) 165 | { 166 | await DeleteApprovalAsync(approval.Id); 167 | } 168 | } 169 | 170 | public static async Task AddGraphLog(GraphLogEntry entry) 171 | { 172 | Document document = await client.CreateDocumentAsync( 173 | UriFactory.CreateDocumentCollectionUri(databaseName, loggingCollectionName), 174 | entry); 175 | 176 | return document; 177 | } 178 | } 179 | } -------------------------------------------------------------------------------- /ApprovalBot/Helpers/EmailHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Mail; 3 | 4 | namespace ApprovalBot.Helpers 5 | { 6 | public static class EmailHelper 7 | { 8 | public static bool IsValidSmtpAddress(string address) 9 | { 10 | try 11 | { 12 | new MailAddress(address); 13 | return true; 14 | } 15 | catch (FormatException) 16 | { 17 | return false; 18 | } 19 | } 20 | 21 | public static string[] ConvertDelimitedAddressStringToArray(string addressString) 22 | { 23 | string[] addresses = addressString.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); 24 | 25 | foreach(string address in addresses) 26 | { 27 | if (!IsValidSmtpAddress(address)) 28 | { 29 | return null; 30 | } 31 | } 32 | 33 | return addresses; 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /ApprovalBot/Helpers/GraphHelper.cs: -------------------------------------------------------------------------------- 1 | using AdaptiveCards; 2 | using ApprovalBot.Models; 3 | using Microsoft.Bot.Builder.Dialogs; 4 | using Microsoft.Graph; 5 | using Newtonsoft.Json; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Configuration; 9 | using System.IO; 10 | using System.Linq; 11 | using System.Net.Http; 12 | using System.Threading; 13 | using System.Threading.Tasks; 14 | 15 | namespace ApprovalBot.Helpers 16 | { 17 | public static class GraphHelper 18 | { 19 | private static readonly bool LogGraphRequests = 20 | string.IsNullOrEmpty(ConfigurationManager.AppSettings["LogGraphRequests"]) ? false : 21 | Convert.ToBoolean(ConfigurationManager.AppSettings["LogGraphRequests"]); 22 | 23 | public static async Task GetFilePickerCardFromOneDrive(string accessToken) 24 | { 25 | var client = GetAuthenticatedClient(accessToken); 26 | 27 | // Get the first 20 items from the root of user's OneDrive 28 | var driveItems = await client.Me.Drive.Root.Children.Request() 29 | .Select("id,name,file") 30 | .Top(20) 31 | .GetAsync(); 32 | 33 | var fileList = new List(); 34 | 35 | while (driveItems != null) 36 | { 37 | foreach (var item in driveItems) 38 | { 39 | // Only process files 40 | if (item.File != null) 41 | { 42 | fileList.Add(new AdaptiveChoice() 43 | { 44 | Title = item.Name, 45 | Value = item.Id 46 | }); 47 | } 48 | } 49 | 50 | if (driveItems.NextPageRequest != null) 51 | { 52 | driveItems = await driveItems.NextPageRequest.GetAsync(); 53 | } 54 | else 55 | { 56 | driveItems = null; 57 | } 58 | } 59 | 60 | var pickerCard = new AdaptiveCard(); 61 | 62 | if (fileList.Count > 0) 63 | { 64 | pickerCard.Body.Add(new AdaptiveTextBlock() 65 | { 66 | Text = "Get approval for which file?", 67 | Size = AdaptiveTextSize.Large, 68 | Weight = AdaptiveTextWeight.Bolder 69 | }); 70 | 71 | pickerCard.Body.Add(new AdaptiveTextBlock() 72 | { 73 | Text = "I found these in your OneDrive", 74 | Weight = AdaptiveTextWeight.Bolder 75 | }); 76 | 77 | pickerCard.Body.Add(new AdaptiveChoiceSetInput() 78 | { 79 | Id = "selectedFile", 80 | IsMultiSelect = false, 81 | Style = AdaptiveChoiceInputStyle.Compact, 82 | Choices = fileList 83 | }); 84 | 85 | pickerCard.Actions.Add(new AdaptiveSubmitAction() 86 | { 87 | Title = "OK", 88 | DataJson = $@"{{ ""cardAction"": ""{CardActionTypes.SelectFile}"" }}" 89 | }); 90 | 91 | return pickerCard; 92 | } 93 | 94 | return null; 95 | } 96 | 97 | public static async Task GetFileDetailCard(string accessToken, string fileId) 98 | { 99 | var client = GetAuthenticatedClient(accessToken); 100 | 101 | // Get the file with thumbnails 102 | var file = await client.Me.Drive.Items[fileId].Request() 103 | .Expand("thumbnails") 104 | .GetAsync(); 105 | 106 | // Get people user interacts with regularly 107 | var potentialApprovers = await client.Me.People.Request() 108 | // Only want organizational users, and do not want to send back to bot 109 | .Filter("personType/subclass eq 'OrganizationUser' and displayName ne 'Approval Bot'") 110 | .Top(10) 111 | .GetAsync(); 112 | 113 | var fileCard = new AdaptiveCard(); 114 | 115 | fileCard.Body.Add(new AdaptiveTextBlock() 116 | { 117 | Text = "Get approval for this file?", 118 | Size = AdaptiveTextSize.Large, 119 | Weight = AdaptiveTextWeight.Bolder 120 | }); 121 | 122 | fileCard.Body.Add(new AdaptiveTextBlock() 123 | { 124 | Text = file.Name, 125 | Weight = AdaptiveTextWeight.Bolder 126 | }); 127 | 128 | fileCard.Body.Add(new AdaptiveFactSet() 129 | { 130 | Facts = new List() 131 | { 132 | new AdaptiveFact("Size", $"{file.Size / 1024} KB"), 133 | new AdaptiveFact("Last modified", TimeZoneHelper.GetAdaptiveDateTimeString(file.LastModifiedDateTime.Value)), 134 | new AdaptiveFact("Last modified by", file.LastModifiedBy.User.DisplayName) 135 | } 136 | }); 137 | 138 | if (file.Thumbnails != null && file.Thumbnails.Count > 0) 139 | { 140 | fileCard.Body.Add(new AdaptiveImage() 141 | { 142 | Size = AdaptiveImageSize.Stretch, 143 | AltText = "File thumbnail", 144 | Url = new Uri(file.Thumbnails[0].Large.Url) 145 | }); 146 | } 147 | 148 | var recipientPromptCard = new AdaptiveCard(); 149 | 150 | recipientPromptCard.Body.Add(new AdaptiveTextBlock() 151 | { 152 | Text = "Who should I ask for approval?", 153 | Weight = AdaptiveTextWeight.Bolder 154 | }); 155 | 156 | var recipientPicker = new AdaptiveChoiceSetInput() 157 | { 158 | Id = "approvers", 159 | IsMultiSelect = true, 160 | Style = AdaptiveChoiceInputStyle.Compact 161 | }; 162 | 163 | foreach(var potentialApprover in potentialApprovers) 164 | { 165 | recipientPicker.Choices.Add(new AdaptiveChoice() 166 | { 167 | Title = potentialApprover.DisplayName, 168 | Value = potentialApprover.ScoredEmailAddresses.First().Address 169 | }); 170 | } 171 | 172 | recipientPromptCard.Body.Add(recipientPicker); 173 | 174 | recipientPromptCard.Actions.Add(new AdaptiveSubmitAction() 175 | { 176 | Title = "Send Request", 177 | DataJson = $@"{{ ""cardAction"": ""{CardActionTypes.SendApprovalRequest}"", ""selectedFile"": ""{fileId}"" }}" 178 | }); 179 | 180 | fileCard.Actions.Add(new AdaptiveShowCardAction() 181 | { 182 | Title = "Yes", 183 | Card = recipientPromptCard 184 | }); 185 | 186 | fileCard.Actions.Add(new AdaptiveSubmitAction() 187 | { 188 | Title = "No", 189 | DataJson = $@"{{ ""cardAction"": ""{CardActionTypes.WrongFile}"" }}" 190 | }); 191 | 192 | return fileCard; 193 | } 194 | 195 | public static async Task GetFileInfo(string accessToken, string fileId) 196 | { 197 | var client = GetAuthenticatedClient(accessToken); 198 | 199 | // Get the file with thumbnails 200 | var file = await client.Me.Drive.Items[fileId].Request() 201 | .Select("id,name") 202 | .Expand("thumbnails") 203 | .GetAsync(); 204 | 205 | // Get a sharing link 206 | var sharingLink = await client.Me.Drive.Items[fileId] 207 | .CreateLink("view", "organization") 208 | .Request() 209 | .PostAsync(); 210 | 211 | return new ApprovalFileInfo() 212 | { 213 | Id = fileId, 214 | Name = file.Name, 215 | SharingUrl = sharingLink.Link.WebUrl, 216 | ThumbnailUrl = (file.Thumbnails != null && file.Thumbnails.Count > 0) ? file.Thumbnails[0].Small.Url : null 217 | }; 218 | } 219 | 220 | public static async Task GetUser(string accessToken) 221 | { 222 | var client = GetAuthenticatedClient(accessToken); 223 | 224 | return await client.Me.Request() 225 | .Select("displayName, mail") 226 | .GetAsync(); 227 | } 228 | 229 | public static async Task GetUserPhotoDataUri(string accessToken, string size) 230 | { 231 | var client = GetAuthenticatedClient(accessToken); 232 | 233 | try 234 | { 235 | var photo = await client.Me.Photos[size].Content.Request().GetAsync(); 236 | 237 | var photoStream = new MemoryStream(); 238 | photo.CopyTo(photoStream); 239 | 240 | var photoBytes = photoStream.ToArray(); 241 | 242 | return string.Format("data:image/png;base64,{0}", 243 | Convert.ToBase64String(photoBytes)); 244 | } 245 | catch (Exception) 246 | { 247 | return null; 248 | } 249 | } 250 | 251 | public static async Task GetFileThumbnailDataUri(string accessToken, string fileId) 252 | { 253 | var client = GetAuthenticatedClient(accessToken); 254 | 255 | try 256 | { 257 | var thumbnail = await client.Me.Drive.Items[fileId].Thumbnails["0"]["small"].Content.Request().GetAsync(); 258 | 259 | var thumbnailStream = new MemoryStream(); 260 | thumbnail.CopyTo(thumbnailStream); 261 | 262 | var thumbnailBytes = thumbnailStream.ToArray(); 263 | 264 | return string.Format("data:image/png;base64,{0}", 265 | Convert.ToBase64String(thumbnailBytes)); 266 | } 267 | catch (Exception) 268 | { 269 | return null; 270 | } 271 | } 272 | 273 | public static async Task SendRequestCard(string accessToken, AdaptiveCard card, string recipient, string sender) 274 | { 275 | var toRecipient = new Recipient() 276 | { 277 | EmailAddress = new EmailAddress() { Address = recipient } 278 | }; 279 | 280 | var actionableMessage = new Message() 281 | { 282 | Subject = "Request for release approval", 283 | ToRecipients = new List() { toRecipient }, 284 | Body = new ItemBody() 285 | { 286 | ContentType = BodyType.Html, 287 | Content = HtmlBodyFromCard(card) 288 | } 289 | }; 290 | 291 | if (!string.IsNullOrEmpty(sender)) 292 | { 293 | actionableMessage.From = new Recipient() { 294 | EmailAddress = new EmailAddress() { Address = sender } 295 | }; 296 | } 297 | 298 | var client = GetAuthenticatedClient(accessToken); 299 | 300 | await client.Me.SendMail(actionableMessage, true).Request().PostAsync(); 301 | } 302 | 303 | private const string htmlBodyTemplate = @" 304 | 305 | 306 | 309 | 310 | 311 |
You've received a request for file approval. If you cannot see the approval card in this message, please visit the Approval Portal to respond to this request.
312 | 313 | "; 314 | 315 | private static string HtmlBodyFromCard(AdaptiveCard card) 316 | { 317 | var cardPayload = JsonConvert.SerializeObject(card); 318 | return string.Format(htmlBodyTemplate, cardPayload); 319 | } 320 | 321 | private static GraphServiceClient GetAuthenticatedClient(string accessToken) 322 | { 323 | if (LogGraphRequests) 324 | return new GraphServiceClient(new DelegateAuthenticationProvider( 325 | async (requestMessage) => 326 | { 327 | requestMessage.Headers.Authorization = 328 | new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); 329 | await Task.FromResult(0); 330 | }), 331 | new HttpProvider(new LoggingHttpProvider(), true, null)); 332 | 333 | return new GraphServiceClient(new DelegateAuthenticationProvider( 334 | async (requestMessage) => 335 | { 336 | requestMessage.Headers.Authorization = 337 | new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); 338 | await Task.FromResult(0); 339 | })); 340 | } 341 | } 342 | 343 | public class LoggingHttpProvider : HttpClientHandler 344 | { 345 | protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 346 | { 347 | // Log request 348 | string requestId = Guid.NewGuid().ToString(); 349 | request.Headers.Add("client-request-id", requestId); 350 | var requestLog = new GraphLogEntry(request); 351 | await requestLog.LoadBody(request); 352 | await DatabaseHelper.AddGraphLog(requestLog); 353 | 354 | try 355 | { 356 | HttpResponseMessage response = await base.SendAsync(request, cancellationToken); 357 | 358 | // Log response 359 | var responseLog = new GraphLogEntry(response); 360 | await responseLog.LoadBody(response); 361 | await DatabaseHelper.AddGraphLog(responseLog); 362 | 363 | return response; 364 | } 365 | catch (Exception ex) 366 | { 367 | await DatabaseHelper.AddGraphLog(new GraphLogEntry(ex, requestId)); 368 | throw; 369 | } 370 | } 371 | } 372 | 373 | public class GraphLogEntry 374 | { 375 | public string RequestId { get; set; } 376 | public string RequestUrl { get; set; } 377 | public string RequestMethod { get; set; } 378 | public string Body { get; set; } 379 | 380 | public GraphLogEntry(HttpRequestMessage request) 381 | { 382 | RequestId = request.Headers.Where(h => string.Equals(h.Key, "client-request-id", StringComparison.OrdinalIgnoreCase)).FirstOrDefault().Value?.FirstOrDefault(); 383 | RequestUrl = request.RequestUri.ToString(); 384 | RequestMethod = request.Method.ToString(); 385 | } 386 | 387 | public GraphLogEntry(HttpResponseMessage response) 388 | { 389 | RequestId = response.Headers.Where(h => string.Equals(h.Key, "client-request-id", StringComparison.OrdinalIgnoreCase)).FirstOrDefault().Value?.FirstOrDefault(); 390 | } 391 | 392 | public GraphLogEntry(Exception ex, string requestId) 393 | { 394 | RequestId = requestId; 395 | RequestMethod = "EXCEPTION"; 396 | Body = ex.ToString(); 397 | } 398 | 399 | public async Task LoadBody(HttpRequestMessage request) 400 | { 401 | if (request.Content != null) 402 | { 403 | Body = await request.Content.ReadAsStringAsync(); 404 | } 405 | } 406 | 407 | public async Task LoadBody(HttpResponseMessage response) 408 | { 409 | if (response.Content != null) 410 | { 411 | Body = await response.Content.ReadAsStringAsync(); 412 | } 413 | } 414 | } 415 | } -------------------------------------------------------------------------------- /ApprovalBot/Helpers/TimeZoneHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | 6 | namespace ApprovalBot.Helpers 7 | { 8 | public static class TimeZoneHelper 9 | { 10 | public static string GetAdaptiveDateTimeString(DateTimeOffset dateTimeOffset) 11 | { 12 | var rfc3389String = $"{dateTimeOffset.UtcDateTime.ToString("s")}Z"; 13 | 14 | // Returns string like 15 | // {{DATE(2018-04-26T07:00:00Z,SHORT)}} {{TIME(2018-04-26T07:00:00Z)}} 16 | // See docs at https://docs.microsoft.com/en-us/adaptive-cards/create/textfeatures#datetime-function-rules 17 | return $"{{{{DATE({rfc3389String},SHORT)}}}} {{{{TIME({rfc3389String})}}}}"; 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /ApprovalBot/Models/ActionData.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json.Linq; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Web; 6 | 7 | namespace ApprovalBot.Models 8 | { 9 | public static class CardActionTypes 10 | { 11 | public static string SelectFile = "selectFile"; 12 | public static string WrongFile = "wrongFile"; 13 | public static string SendApprovalRequest = "sendApproval"; 14 | public static string SelectApproval = "selectApproval"; 15 | } 16 | 17 | public class ActionData 18 | { 19 | public string CardAction { get; set; } 20 | public string SelectedFile { get; set; } 21 | public string Approvers { get; set; } 22 | public string SelectedApproval { get; set; } 23 | 24 | public static ActionData Parse(object obj) 25 | { 26 | return ((JToken)obj).ToObject(); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /ApprovalBot/Models/ActionableEmailResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | 6 | namespace ApprovalBot.Models 7 | { 8 | public class ActionableEmailResponse 9 | { 10 | public string UserEmail { get; set; } 11 | public string ApprovalId { get; set; } 12 | public string Response { get; set; } 13 | public string Notes { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /ApprovalBot/Models/Approval.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | 5 | namespace ApprovalBot.Models 6 | { 7 | public class Approval 8 | { 9 | [JsonProperty(PropertyName = "id")] 10 | public string Id { get; set; } 11 | [JsonProperty(PropertyName = "requestor")] 12 | public string Requestor { get; set; } 13 | [JsonProperty(PropertyName = "file")] 14 | public ApprovalFileInfo File { get; set; } 15 | [JsonProperty(PropertyName = "approvers")] 16 | public List Approvers { get; set; } 17 | [JsonProperty(PropertyName = "requestDate")] 18 | public DateTimeOffset RequestDate { get; set; } 19 | } 20 | } -------------------------------------------------------------------------------- /ApprovalBot/Models/ApproverInfo.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Web; 6 | 7 | namespace ApprovalBot.Models 8 | { 9 | public enum ResponseStatus 10 | { 11 | NotResponded, 12 | Approved, 13 | Rejected 14 | } 15 | 16 | public class ApproverInfo 17 | { 18 | [JsonProperty(PropertyName = "emailAddress")] 19 | public string EmailAddress { get; set; } 20 | [JsonProperty(PropertyName = "response")] 21 | public ResponseStatus Response { get; set; } 22 | [JsonProperty(PropertyName = "responseNote")] 23 | public string ResponseNote { get; set; } 24 | } 25 | } -------------------------------------------------------------------------------- /ApprovalBot/Models/FileInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | 6 | namespace ApprovalBot.Models 7 | { 8 | public class ApprovalFileInfo 9 | { 10 | public string Id { get; set; } 11 | public string Name { get; set; } 12 | public string SharingUrl { get; set; } 13 | public string ThumbnailUrl { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /ApprovalBot/OutlookAdaptive/AdaptiveActionSet.cs: -------------------------------------------------------------------------------- 1 | using AdaptiveCards; 2 | using Newtonsoft.Json; 3 | using System.Collections.Generic; 4 | using System.ComponentModel; 5 | 6 | namespace ApprovalBot.OutlookAdaptive 7 | { 8 | public class AdaptiveActionSet : AdaptiveElement 9 | { 10 | public const string TypeName = "ActionSet"; 11 | 12 | public override string Type { get; set; } = TypeName; 13 | 14 | public AdaptiveActionSet(){} 15 | 16 | [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] 17 | [DefaultValue(typeof(AdaptiveHorizontalAlignment), "left")] 18 | public AdaptiveHorizontalAlignment HorizontalAlignment { get; set; } 19 | 20 | [JsonRequired] 21 | public List Actions { get; set; } = new List(); 22 | } 23 | } -------------------------------------------------------------------------------- /ApprovalBot/OutlookAdaptive/AdaptiveHttpAction.cs: -------------------------------------------------------------------------------- 1 | using AdaptiveCards; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Converters; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.ComponentModel; 7 | 8 | namespace ApprovalBot.OutlookAdaptive 9 | { 10 | [JsonConverter(typeof(StringEnumConverter), true)] 11 | public enum AdaptiveHttpActionMethod 12 | { 13 | GET, 14 | POST 15 | } 16 | 17 | public class AdaptiveHttpActionHeader 18 | { 19 | [JsonRequired] 20 | public string Name { get; set; } 21 | 22 | [JsonRequired] 23 | public string Value { get; set; } 24 | } 25 | 26 | public class AdaptiveHttpAction : AdaptiveAction 27 | { 28 | public const string TypeName = "Action.Http"; 29 | 30 | public override string Type { get; set; } = TypeName; 31 | 32 | [JsonRequired] 33 | public AdaptiveHttpActionMethod Method { get; set; } 34 | 35 | [JsonRequired] 36 | public Uri Url { get; set; } 37 | 38 | [DefaultValue(null)] 39 | public List Headers { get; set; } 40 | 41 | [DefaultValue(null)] 42 | public string Body { get; set; } 43 | } 44 | } -------------------------------------------------------------------------------- /ApprovalBot/OutlookAdaptive/AdaptiveToggleVisibilityAction.cs: -------------------------------------------------------------------------------- 1 | using AdaptiveCards; 2 | using Newtonsoft.Json; 3 | 4 | namespace ApprovalBot.OutlookAdaptive 5 | { 6 | public class AdaptiveToggleVisibilityAction : AdaptiveAction 7 | { 8 | public const string TypeName = "Action.ToggleVisibility"; 9 | 10 | public override string Type { get; set; } = TypeName; 11 | 12 | [JsonRequired] 13 | public string[] TargetElements { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /ApprovalBot/PrivateSettings.example.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ApprovalBot/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("ApprovalBot")] 9 | [assembly: AssemblyDescription("")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct("ApprovalBot")] 13 | [assembly: AssemblyCopyright("Copyright © 2016")] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("4e426b56-d589-44ea-b7a1-72ed5343b354")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Revision and Build Numbers 33 | // by using the '*' as shown below: 34 | [assembly: AssemblyVersion("1.0.0.0")] 35 | [assembly: AssemblyFileVersion("1.0.0.0")] 36 | -------------------------------------------------------------------------------- /ApprovalBot/Web.Debug.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 17 | 18 | 29 | 30 | -------------------------------------------------------------------------------- /ApprovalBot/Web.Release.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 17 | 18 | 19 | 30 | 31 | -------------------------------------------------------------------------------- /ApprovalBot/Web.config: -------------------------------------------------------------------------------- 1 |  2 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /ApprovalBot/default.htm: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 |

ApprovalBot

9 |

Describe your bot here and your terms of use etc.

10 |

Visit Bot Framework to register your bot. When you register it, remember to set your bot's endpoint to

https://your_bots_hostname/api/messages

11 | 12 | 13 | -------------------------------------------------------------------------------- /ApprovalBot/packages.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | - Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | products: 4 | - office-outlook 5 | - office-onedrive 6 | - ms-graph 7 | languages: 8 | - csharp 9 | description: "A sample Bot that uses adaptive cards and the .NET Graph SDK to send actionable messages requesting approval to release files on OneDrive." 10 | extensions: 11 | contentType: samples 12 | technologies: 13 | - Microsoft Graph 14 | - Microsoft Bot Framework 15 | services: 16 | - Outlook 17 | - OneDrive 18 | createdDate: 4/23/2018 12:12:07 PM 19 | --- 20 | # Approval Bot Sample 21 | 22 | ## Running locally 23 | 24 | Follow these steps to enable running the bot locally for debugging. 25 | 26 | ### Prerequisites 27 | 28 | - [ngrok](https://ngrok.com/) 29 | - [Bot Framework Emulator](https://github.com/Microsoft/BotFramework-Emulator/releases) 30 | - [Azure Cosmos DB Emulator](https://docs.microsoft.com/en-us/azure/cosmos-db/local-emulator) 31 | - Visual Studio 2017 32 | 33 | ### Register the app 34 | 35 | 1. Open a browser and navigate to the [Azure Active Directory admin center](https://aad.portal.azure.com). Login using a **Work or School Account**. 36 | 37 | 1. Select **Azure Active Directory** in the left-hand navigation, then select **App registrations (Preview)** under **Manage**. 38 | 39 | ![A screenshot of the App registrations ](readme-images/aad-portal-app-registrations.png) 40 | 41 | 1. Select **New registration**. On the **Register an application** page, set the values as follows. 42 | 43 | - Set a preferred **Name** e.g. `Approval Bot`. 44 | - Set **Supported account types** to **Accounts in any organizational directory**. 45 | - Under **Redirect URI**, set the first drop-down to `Web` and set the value to `http://localhost:3979/callback`. 46 | 47 | ![A screenshot of the Register an application page](readme-images/aad-register-an-app.PNG) 48 | 49 | 1. Choose **Register**. On the **Approval Bot** page, copy the value of the **Application (client) ID** and save it, you will need it in the next step. 50 | 51 | ![A screenshot of the application ID of the new app registration](readme-images/aad-application-id.PNG) 52 | 53 | 1. Select **Certificates & secrets** under **Manage**. Select the **New client secret** button. Enter a value in **Description** and select one of the options for **Expires** and choose **Add**. 54 | 55 | ![A screenshot of the Add a client secret dialog](readme-images/aad-new-client-secret.png) 56 | 57 | 1. Copy the client secret value before you leave this page. You will need it in the next step. 58 | 59 | > [!IMPORTANT] 60 | > This client secret is never shown again, so make sure you copy it now. 61 | 62 | ![A screenshot of the newly added client secret](readme-images/aad-copy-client-secret.png) 63 | 64 | ### Set up the ngrok proxy 65 | 66 | You must expose a public HTTPS endpoint to receive notifications from the Bot Framework Emulator. While testing, you can use ngrok to temporarily allow messages from the Bot Framework Emulator to tunnel to a *localhost* port on your computer. 67 | 68 | You can use the ngrok web interface ([http://127.0.0.1:4040](http://127.0.0.1:4040)) to inspect the HTTP traffic that passes through the tunnel. To learn more about using ngrok, see the [ngrok website](https://ngrok.com/). 69 | 70 | 71 | 1. [Download ngrok](https://ngrok.com/download) for Windows. 72 | 73 | 1. Unzip the package and run ngrok.exe. 74 | 75 | 1. Run the following command line on the ngrok console: 76 | 77 | ```Shell 78 | ngrok http 3979 --host-header=localhost:3979 79 | ``` 80 | 81 | ![Example command to run in the ngrok console](readme-images/ngrok1.PNG) 82 | 83 | 1. Copy the HTTPS URL that's shown in the console. You'll use this to configure your `NgrokRootUrl` in the sample. 84 | 85 | ![The forwarding HTTPS URL in the ngrok console](readme-images/ngrok2.PNG) 86 | 87 | > **Note:** Keep the console open while testing. If you close it, the tunnel also closes and you'll need to generate a new URL and update the sample. 88 | 89 | ### Configure and run the sample 90 | 91 | 1. Clone the repository locally. 92 | 1. Make a copy of the **./ApprovalBot/PrivateSettings.example.config** file in the same directory, and name the copy `PrivateSettings.config`. 93 | 1. Open **ApprovalBot.sln** in Visual Studio, then open the **PrivateSettings.config** file. 94 | 95 | 1. Set the value of `MicrosoftAppId` to the Application (client) ID you generated in the previous step, and set the value of `MicrosoftAppPassword` to the secret you generated afterwards. 96 | 97 | 1. Paste the ngrok HTTPS URL value copied from the previous step into the value of `NgrokRootUrl` in **PrivateSettings.config**, and save your changes. 98 | 99 | > **IMPORTANT**: Leave ngrok running while you run the sample. If you stop ngrok and re-start it, the forwarding URL changes, and you'll need to update the value of `NgrokRootUrl`. 100 | 101 | 1. Start the Azure Cosmos DB Emulator. This needs to be running before you start the sample. 102 | 103 | 1. Press F5 to debug the sample. 104 | 105 | 1. Run the Bot Framework Emulator. At the top, where it says **Enter your endpoint URL**, enter `https://localhost:3979/api/messages`. 106 | 107 | 1. That will prompt for app ID and password. Enter your app ID and secret, and leave **Locale** blank. 108 | 109 | ![](readme-images/configure-emulator.PNG) 110 | 111 | 1. Click **Connect**. 112 | 113 | 1. Send `hi` to confirm the connection. 114 | 115 | ![](readme-images/hello-bot.PNG) 116 | 117 | ## Limitations when running locally 118 | 119 | When running the sample locally, the approval request email that sent is a little different. Because it's not running on a confirmed, registered domain, we must send the message to and from the same account. What that means is that when you get to the point where you request approval, you must include yourself in the list of approvers. 120 | 121 | You can include other approvers, but the message they receive won't show the adaptive card. Login to your own mailbox with Outlook to test the adaptive card. 122 | 123 | ## Contributing 124 | 125 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 126 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 127 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 128 | 129 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 130 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 131 | provided by the bot. You will only need to do this once across all repos using our CLA. 132 | 133 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 134 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 135 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 136 | 137 | ## Copyright 138 | 139 | Copyright (c) 2019 Microsoft. All rights reserved. 140 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /readme-images/aad-application-id.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/botframework-csharp-approvalbot-sample/172034b3d80935efce98094f2ebb2971812d9da9/readme-images/aad-application-id.PNG -------------------------------------------------------------------------------- /readme-images/aad-copy-client-secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/botframework-csharp-approvalbot-sample/172034b3d80935efce98094f2ebb2971812d9da9/readme-images/aad-copy-client-secret.png -------------------------------------------------------------------------------- /readme-images/aad-new-client-secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/botframework-csharp-approvalbot-sample/172034b3d80935efce98094f2ebb2971812d9da9/readme-images/aad-new-client-secret.png -------------------------------------------------------------------------------- /readme-images/aad-portal-app-registrations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/botframework-csharp-approvalbot-sample/172034b3d80935efce98094f2ebb2971812d9da9/readme-images/aad-portal-app-registrations.png -------------------------------------------------------------------------------- /readme-images/aad-register-an-app.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/botframework-csharp-approvalbot-sample/172034b3d80935efce98094f2ebb2971812d9da9/readme-images/aad-register-an-app.PNG -------------------------------------------------------------------------------- /readme-images/configure-emulator.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/botframework-csharp-approvalbot-sample/172034b3d80935efce98094f2ebb2971812d9da9/readme-images/configure-emulator.PNG -------------------------------------------------------------------------------- /readme-images/hello-bot.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/botframework-csharp-approvalbot-sample/172034b3d80935efce98094f2ebb2971812d9da9/readme-images/hello-bot.PNG -------------------------------------------------------------------------------- /readme-images/ngrok1.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/botframework-csharp-approvalbot-sample/172034b3d80935efce98094f2ebb2971812d9da9/readme-images/ngrok1.PNG -------------------------------------------------------------------------------- /readme-images/ngrok2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/botframework-csharp-approvalbot-sample/172034b3d80935efce98094f2ebb2971812d9da9/readme-images/ngrok2.PNG --------------------------------------------------------------------------------