├── .github └── workflows │ └── SubscriptionsUpdate.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── TODO.md ├── ThirdPartyNotices.txt ├── build ├── Build.yaml ├── StorageDeploy.ps1 └── SubscriptionsUpdate.yaml ├── config ├── Subscriptions.json ├── deployCode.ps1 ├── deployInfrastructure.ps1 ├── main.bicep ├── main.json ├── main.parameters.json └── storage.json ├── docs └── Architecture.drawio ├── scripts ├── ExampleSubscriptionCallbacks.ps1 ├── ExampleUsage.ps1 ├── Invoke-TwitchSubscriptionRegistration.ps1 └── RegisteringSubscriptions.ps1 └── src ├── Directory.Build.props ├── Models ├── DiscordMessage.cs ├── Stream.cs ├── StreamData.cs ├── TwitchChannel.cs ├── TwitchChannelEvent.cs ├── TwitchChannelEventItem.cs ├── TwitchChannelEventResponse.cs ├── TwitchGame.cs ├── TwitchGamesData.cs ├── TwitchHubSubscription.cs ├── TwitchImage.cs ├── TwitchNotificationsEntry.cs ├── TwitchOAuthResponse.cs ├── TwitchScheduledChannelEvent.cs ├── TwitchScheduledChannelEventType.cs ├── TwitchSubscription.cs ├── TwitchSubscriptionData.cs ├── TwitchSubscriptionRegistrationResponse.cs ├── TwitchUser.cs ├── TwitchUserData.cs ├── TwitchWebhookSubscription.cs ├── TwitchWebhookSubscriptionData.cs └── TwtchGame.cs ├── TwitchStreamNotifcations.csproj ├── functions ├── DiscordEventHandler.cs ├── DiscordScheduledEventNotifier.cs ├── TwitchChannelEventLookup.cs ├── TwitchChannelEventProcess.cs ├── TwitchScheduledGetSubscriptions.cs ├── TwitchScheduledSubscriptionRegistration.cs ├── TwitchStreamEventHandler.cs ├── TwitchSubscriptionAdd.cs ├── TwitchSubscriptionRegistration.cs ├── TwitchSubscriptionRemove.cs ├── TwitchWebhookIngestion.cs ├── TwitterEventHandler.cs └── TwitterScheduledEventNotifier.cs ├── host.json └── utilities ├── DiscordClient.cs ├── TwitchClient.cs ├── TwitterClient.cs └── Utility.cs /.github/workflows/SubscriptionsUpdate.yml: -------------------------------------------------------------------------------- 1 | name: UploadSubscriptions 2 | 3 | on: 4 | push: 5 | branches: master 6 | paths: config/Subscriptions.json 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | SubscriptionsUpdate: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Upload subscriptions to file share 20 | uses: azure/CLI@v1 21 | with: 22 | azcliversion: 2.19.1 23 | inlineScript: | 24 | az storage blob upload \ 25 | --container-name 'twitchsubscriptions' \ 26 | --file 'config/Subscriptions.json' \ 27 | --name 'Subscriptions.json' \ 28 | --account-name ${{ secrets.AZURE_STORAGE_ACCOUNT }} \ 29 | --account-key ${{ secrets.AZURE_STORAGE_KEY }} 30 | 31 | - name: Trigger update Subscriptions 32 | shell: pwsh 33 | run: ./scripts/Invoke-TwitchSubscriptionRegistration.ps1 34 | env: 35 | FunctionCode: ${{ secrets.SUBSCRIPTION_REGISTRATION_FUNCTION_CODE }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # Azure Functions localsettings file 5 | local.settings.json 6 | 7 | # User-specific files 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | bld/ 24 | [Bb]in/ 25 | [Oo]bj/ 26 | [Ll]og/ 27 | 28 | # Visual Studio 2015 cache/options directory 29 | .vs/ 30 | # Uncomment if you have tasks that create the project's static files in wwwroot 31 | #wwwroot/ 32 | 33 | # MSTest test Results 34 | [Tt]est[Rr]esult*/ 35 | [Bb]uild[Ll]og.* 36 | 37 | # NUNIT 38 | *.VisualState.xml 39 | TestResult.xml 40 | 41 | # Build Results of an ATL Project 42 | [Dd]ebugPS/ 43 | [Rr]eleasePS/ 44 | dlldata.c 45 | 46 | # DNX 47 | project.lock.json 48 | project.fragment.lock.json 49 | artifacts/ 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # NCrunch 117 | _NCrunch_* 118 | .*crunch*.local.xml 119 | nCrunchTemp_* 120 | 121 | # MightyMoose 122 | *.mm.* 123 | AutoTest.Net/ 124 | 125 | # Web workbench (sass) 126 | .sass-cache/ 127 | 128 | # Installshield output folder 129 | [Ee]xpress/ 130 | 131 | # DocProject is a documentation generator add-in 132 | DocProject/buildhelp/ 133 | DocProject/Help/*.HxT 134 | DocProject/Help/*.HxC 135 | DocProject/Help/*.hhc 136 | DocProject/Help/*.hhk 137 | DocProject/Help/*.hhp 138 | DocProject/Help/Html2 139 | DocProject/Help/html 140 | 141 | # Click-Once directory 142 | publish/ 143 | 144 | # Publish Web Output 145 | *.[Pp]ublish.xml 146 | *.azurePubxml 147 | # TODO: Comment the next line if you want to checkin your web deploy settings 148 | # but database connection strings (with potential passwords) will be unencrypted 149 | #*.pubxml 150 | *.publishproj 151 | 152 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 153 | # checkin your Azure Web App publish settings, but sensitive information contained 154 | # in these scripts will be unencrypted 155 | PublishScripts/ 156 | 157 | # NuGet Packages 158 | *.nupkg 159 | # The packages folder can be ignored because of Package Restore 160 | **/packages/* 161 | # except build/, which is used as an MSBuild target. 162 | !**/packages/build/ 163 | # Uncomment if necessary however generally it will be regenerated when needed 164 | #!**/packages/repositories.config 165 | # NuGet v3's project.json files produces more ignoreable files 166 | *.nuget.props 167 | *.nuget.targets 168 | 169 | # Microsoft Azure Build Output 170 | csx/ 171 | *.build.csdef 172 | 173 | # Microsoft Azure Emulator 174 | ecf/ 175 | rcf/ 176 | 177 | # Windows Store app package directories and files 178 | AppPackages/ 179 | BundleArtifacts/ 180 | Package.StoreAssociation.xml 181 | _pkginfo.txt 182 | 183 | # Visual Studio cache files 184 | # files ending in .cache can be ignored 185 | *.[Cc]ache 186 | # but keep track of directories ending in .cache 187 | !*.[Cc]ache/ 188 | 189 | # Others 190 | ClientBin/ 191 | ~$* 192 | *~ 193 | *.dbmdl 194 | *.dbproj.schemaview 195 | *.jfm 196 | *.pfx 197 | *.publishsettings 198 | node_modules/ 199 | orleans.codegen.cs 200 | 201 | # Since there are multiple workflows, uncomment next line to ignore bower_components 202 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 203 | #bower_components/ 204 | 205 | # RIA/Silverlight projects 206 | Generated_Code/ 207 | 208 | # Backup & report files from converting an old project file 209 | # to a newer Visual Studio version. Backup files are not needed, 210 | # because we have git ;-) 211 | _UpgradeReport_Files/ 212 | Backup*/ 213 | UpgradeLog*.XML 214 | UpgradeLog*.htm 215 | 216 | # SQL Server files 217 | *.mdf 218 | *.ldf 219 | 220 | # Business Intelligence projects 221 | *.rdl.data 222 | *.bim.layout 223 | *.bim_*.settings 224 | 225 | # Microsoft Fakes 226 | FakesAssemblies/ 227 | 228 | # GhostDoc plugin setting file 229 | *.GhostDoc.xml 230 | 231 | # Node.js Tools for Visual Studio 232 | .ntvs_analysis.dat 233 | 234 | # Visual Studio 6 build log 235 | *.plg 236 | 237 | # Visual Studio 6 workspace options file 238 | *.opt 239 | 240 | # Visual Studio LightSwitch build output 241 | **/*.HTMLClient/GeneratedArtifacts 242 | **/*.DesktopClient/GeneratedArtifacts 243 | **/*.DesktopClient/ModelManifest.xml 244 | **/*.Server/GeneratedArtifacts 245 | **/*.Server/ModelManifest.xml 246 | _Pvt_Extensions 247 | 248 | # Paket dependency manager 249 | .paket/paket.exe 250 | paket-files/ 251 | 252 | # FAKE - F# Make 253 | .fake/ 254 | 255 | # JetBrains Rider 256 | .idea/ 257 | *.sln.iml 258 | 259 | # CodeRush 260 | .cr/ 261 | 262 | # Python Tools for Visual Studio (PTVS) 263 | __pycache__/ 264 | *.pyc -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-dotnettools.csharp" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to C# Functions", 6 | "type": "coreclr", 7 | "request": "attach", 8 | "processId": "${command:azureFunctions.pickProcess}" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.projectRuntime": "~3", 3 | "azureFunctions.projectLanguage": "C#", 4 | "azureFunctions.templateFilter": "Verified", 5 | "azureFunctions.deploySubpath": "src/bin/Release/netcoreapp3.1/publish", 6 | "azureFunctions.preDeployTask": "publish", 7 | "debug.internalConsoleOptions": "neverOpen", 8 | "cSpell.words": [ 9 | "APPINSIGHTS", 10 | "Acls", 11 | "CONTENTAZUREFILECONNECTIONSTRING", 12 | "CONTENTSHARE", 13 | "Enablement", 14 | "HMACSHA", 15 | "INSTRUMENTATIONKEY", 16 | "Notifi", 17 | "Redfield", 18 | "azurewebsites", 19 | "discordeventnotification", 20 | "discordnotifications", 21 | "functionapp", 22 | "serverfarms", 23 | "twitchchanneleventlookup", 24 | "twitchchanneleventprocess", 25 | "twitchnotifications", 26 | "twitchstreamactivity", 27 | "twitchsubscribe", 28 | "twitchunsubscribe", 29 | "twittereventnotification", 30 | "twitternotifications" 31 | ], 32 | "[powershell]": { 33 | "editor.tabSize": 4, 34 | "editor.insertSpaces": true, 35 | "editor.detectIndentation": false, 36 | "editor.trimAutoWhitespace": true 37 | }, 38 | "[json]": { 39 | "editor.tabSize": 2, 40 | "editor.insertSpaces": true, 41 | "editor.detectIndentation": false, 42 | "editor.trimAutoWhitespace": true 43 | }, 44 | "[jsonc]": { 45 | "editor.tabSize": 2, 46 | "editor.insertSpaces": true, 47 | "editor.detectIndentation": false, 48 | "editor.trimAutoWhitespace": true 49 | } 50 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "clean", 6 | "command": "dotnet", 7 | "args": [ 8 | "clean", 9 | "/property:GenerateFullPaths=true", 10 | "/consoleloggerparameters:NoSummary" 11 | ], 12 | "type": "process", 13 | "problemMatcher": "$msCompile", 14 | "options": { 15 | "cwd": "${workspaceFolder}/src" 16 | } 17 | }, 18 | { 19 | "label": "build", 20 | "command": "dotnet", 21 | "args": [ 22 | "build", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "type": "process", 27 | "dependsOn": "clean", 28 | "group": { 29 | "kind": "build", 30 | "isDefault": true 31 | }, 32 | "problemMatcher": "$msCompile", 33 | "options": { 34 | "cwd": "${workspaceFolder}/src" 35 | } 36 | }, 37 | { 38 | "label": "clean release", 39 | "command": "dotnet", 40 | "args": [ 41 | "clean", 42 | "--configuration", 43 | "Release", 44 | "/property:GenerateFullPaths=true", 45 | "/consoleloggerparameters:NoSummary" 46 | ], 47 | "type": "process", 48 | "problemMatcher": "$msCompile", 49 | "options": { 50 | "cwd": "${workspaceFolder}/src" 51 | } 52 | }, 53 | { 54 | "label": "publish", 55 | "command": "dotnet", 56 | "args": [ 57 | "publish", 58 | "--configuration", 59 | "Release", 60 | "/property:GenerateFullPaths=true", 61 | "/consoleloggerparameters:NoSummary" 62 | ], 63 | "type": "process", 64 | "dependsOn": "clean release", 65 | "problemMatcher": "$msCompile", 66 | "options": { 67 | "cwd": "${workspaceFolder}/src" 68 | } 69 | }, 70 | { 71 | "type": "func", 72 | "dependsOn": "build", 73 | "options": { 74 | "cwd": "${workspaceFolder}/src/bin/Debug/netcoreapp3.1" 75 | }, 76 | "command": "host start", 77 | "isBackground": true, 78 | "problemMatcher": "$func-dotnet-watch" 79 | } 80 | ] 81 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mark E. Kraus 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 | # TwitchStreamNotifications 2 | 3 | This is an Azure Functions project which allows for notifications to services such as twitter, slack, and discord when a streaming channel has gone live. 4 | 5 | ## Architecture 6 | 7 | ### Webhook 8 | 9 | Subscribed Twitch webhooks will submit to the `TwitchWebhookIngestion` function. 10 | The `TwitchWebhookIngestion` is responsible to validating the webhook payload and then queues it in the `TwitchStreamActivity` queue. 11 | The `TwitchStreamEventHandler` is triggered by the `TwitchStreamActivity` and is responsible for directing the event to notification handler queues. 12 | For now, the `TwitchStreamEventHandler` only queues the event to the `TwitterNotifications` and `DiscordNotifications` queues. 13 | The `TwitterEventHandler` function is triggered by the `TwitterNotifications` and will create a tweet stating a channel has gone live. 14 | The `DiscordEventHandler` function is triggered by the `DiscordNotifications` and will create a discord message stating a channel has gone live. 15 | 16 | ### Subscription Management 17 | 18 | A JSON payload like the one in [Subscriptions.json](./config/Subscriptions.json) will be posted to `TwitchSubscriptionRegistration` function using the function key. 19 | The `TwitchSubscriptionRegistration` will verify the requested subscriptions against the current subscriptions registered with Twitch. 20 | Requested subscriptions that are missing from the current registered subscriptions will be added to the `TwitchSubscribeQueue` queue. 21 | Currently registered subscriptions that are not included in the requested subscriptions will be added to the `TwitchUnsubscribeQueue` queue. 22 | 23 | The `TwitchSubscriptionAdd` will be triggered by the `TwitchSubscribeQueue` and will send a subscribe request to the Twitch API. 24 | 25 | The `TwitchSubscriptionRemove` will be triggered by the `TwitchUnsubscribeQueue` and will send an unsubscribe request to the Twitch API. 26 | 27 | Subscribe and Unsubscribe requests to the Twitch API will result in the Twitch API making a callback to the `TwitchWebhookIngestion` which will be responsible for returning the `hub.challenge` query parameter back to the Twitch API. 28 | 29 | ## Functions 30 | 31 | ### Webhook Functions 32 | 33 | #### TwitchWebhookIngestion 34 | 35 | * Trigger: Http 36 | * Inputs: HttpRequest, StreamName, TwitterName 37 | * Route: `TwitchWebhookIngestion/{StreamName}/{TwitterName?}` 38 | * Output: `TwitchStreamActivity` queue 39 | 40 | This is the endpoint where Twitch submits webhook payloads. 41 | It is responsible for validating the payload against the provided hash and the calculated hash using the `TwitchSubscriptionsHashSecret`. 42 | Valid webhook payloads are then enqueued in the `TwitchStreamEventHandler queue. 43 | 44 | #### TwitchStreamEventHandler 45 | 46 | * Trigger: `TwitchStreamActivity` queue 47 | * Output: `TwitterNotifications` queue, `DiscordNotifications` queue 48 | 49 | This function is responsible for routing Twitch Stream events to various event handlers. 50 | Currently, the only event handlers are `TwitterEventHandler` and `DiscordEventHandler`. 51 | 52 | #### TwitterEventHandler 53 | 54 | * Trigger: `TwitterNotifications` queue 55 | 56 | This function is an Event handler for Twitter. 57 | Currently, the only action it takes is to create new tweets when a Twitch Stream has gone live. 58 | 59 | #### DiscordEventHandler 60 | 61 | * Trigger: `DiscordNotifications` queue 62 | 63 | This function is an Event handler for Discord. 64 | Currently, the only action it takes is to create new Discord Messages when a Twitch Stream has gone live. 65 | 66 | ### Subscription Management Functions 67 | 68 | #### TwitchSubscriptionRegistration 69 | 70 | * Trigger: Http 71 | * Inputs: HttpRequest (Json payload [Subscriptions.json](./config/Subscriptions.json)) 72 | * Route: `TwitchSubscriptionRegistration` 73 | * Output: `TwitchSubscribeQueue` queue, `TwitchUnsubscribeQueue` 74 | 75 | Provides idempotent Twitch webhook subscription registrations based on the JSON payload. 76 | The function queries the Twitch API for currently registered subscriptions and compares them to the requested subscriptions. 77 | Missing subscriptions will be added to the `TwitchSubscribeQueue`. 78 | Extra subscriptions will be added to the `TwitchUnsubscribeQueue`. 79 | 80 | #### TwitchSubscriptionAdd 81 | 82 | * Trigger: `TwitchSubscribeQueue` 83 | 84 | Subscriptions from the `TwitchSubscribeQueue` will be sent to the Twitch API to be subscribed. 85 | 86 | #### TwitchSubscriptionRemove 87 | 88 | * Trigger: `TwitchUnsubscribeQueue` 89 | 90 | Subscriptions from the `TwitchUnsubscribeQueue` will be sent to the Twitch API to be unsubscribed. 91 | 92 | ## Application Settings 93 | 94 | * `TwitchStreamStorage` - Storage connection string to use for Queues 95 | * `TwitchSubscriptionsHashSecret` - Secret used when subscribing to web hooks. This is used to perform a HMAC SHA265 signature check on the webhook. 96 | * `TwitchStreamActivity` - Queue name for Twitch Stream Events. 97 | * `TwitterNotifications` - Queue name for Twitter Events. 98 | * `TwitterConsumerKey` - Twitter App Consumer API Key. 99 | * `TwitterConsumerSecret` - Twitter App Consumer API secret. 100 | * `TwitterAccessToken` - Twitter Access token for the user that wills send out tweets. 101 | * `TwitterAccessTokenSecret` - Twitter Access Token Secret for the user that wills send out tweets. 102 | * `DISABLE_NOTIFICATIONS` - When set to `true`, notification event handlers (e.g. `TwitterEventHandler`) will not perform notification actions. Used for troubleshooting and debugging. 103 | * `TwitterTweetTemplate` - String format template. Called with `string.Format(TwitterTweetTemplate, streamUri, username, (UTCDateTime), game);` where `streamUri` is the URL to the twitch stream and the `username` is either the twitch stream or twitter handle (if a twitter handle was provided when registering). 104 | * `TwitchSubscribeQueue` - Storage Queue Name for twitch subscriptions to add 105 | * `TwitchUnsubscribeQueue` - Storage Queue Name for twitch subscriptions to remove 106 | * `TwitchClientId` - Twitch APP Client Id used for authenticating to Twitch API 107 | * `TwitchClientSecret` - Twitch APP Client Secret used for authenticating to Twitch API 108 | * `TwitchClientRedirectUri` - Twitch APP ClientRedirect Uri used for authenticating to Twitch API 109 | * `TwitchWebhookBaseUri` - The base URI used for Twitch webhook callbacks. `https://{AzureFuncionsWebAppName}.azurewebsites.net/api/TwitchWebhookIngestion` 110 | * `DiscordWebhookUri` - The URI for the Discord webhook 111 | * `DiscordMessageTemplate` - Message template for Discord messages. 112 | * `DiscordNotifications` - Queue name for Discord Events. -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | 1. Proper cancellation token handling in async operations 4 | 1. Add Table Cleanup Function 5 | 1. Address `--` issue in the `TwitchSubscription` model 6 | -------------------------------------------------------------------------------- /ThirdPartyNotices.txt: -------------------------------------------------------------------------------- 1 | THIRD PARTY SOFTWARE NOTICES AND INFORMATION 2 | 3 | This project uses software provided by third parties, including open source software. The following copyright statements and licenses apply to various components that are distributed with various parts of the project. The project that includes this file does not necessarily use all of the third party software components referred to below. 4 | 5 | Licensee must fully agree and comply with these license terms or must not use these components. The third party license terms apply only to the respective software to which the license pertains, and the third party license terms do not apply to the Hangfire software. 6 | 7 | In the event that we accidentally failed to list a required notice, please bring it to our attention by filing an issue in our code repository. 8 | 9 | ------------------------------------------------------------------- 10 | 11 | Tweetinvi 12 | https://github.com/linvi/tweetinvi 13 | 14 | MIT License 15 | 16 | Copyright (c) 2017 Thomas Imart 17 | 18 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | ------------------------------------------------------------------- 25 | 26 | TwitchLib.Webhook 27 | https://github.com/TwitchLib/TwitchLib.Webhook 28 | 29 | MIT License 30 | 31 | Copyright (c) 2018 TwitchLib 32 | 33 | Permission is hereby granted, free of charge, to any person obtaining a copy 34 | of this software and associated documentation files (the "Software"), to deal 35 | in the Software without restriction, including without limitation the rights 36 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 37 | copies of the Software, and to permit persons to whom the Software is 38 | furnished to do so, subject to the following conditions: 39 | 40 | The above copyright notice and this permission notice shall be included in all 41 | copies or substantial portions of the Software. 42 | 43 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 44 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 45 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 46 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 47 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 48 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 49 | SOFTWARE. 50 | -------------------------------------------------------------------------------- /build/Build.yaml: -------------------------------------------------------------------------------- 1 | pool: 2 | name: Hosted VS2017 3 | 4 | trigger: 5 | branches: 6 | include: 7 | - master 8 | paths: 9 | include: 10 | - src/* 11 | - config/* 12 | exclude: 13 | - config/Subscriptions.json 14 | 15 | steps: 16 | - task: DotNetCoreCLI@2 17 | displayName: 'dotnet restore' 18 | inputs: 19 | command: restore 20 | projects: '**/*.csproj' 21 | workingDirectory: src 22 | 23 | - task: DotNetCoreCLI@2 24 | displayName: 'dotnet build' 25 | inputs: 26 | projects: '**/*.csproj' 27 | arguments: '--configuration Release' 28 | workingDirectory: src 29 | 30 | - task: DotNetCoreCLI@2 31 | displayName: 'dotnet publish' 32 | inputs: 33 | command: publish 34 | publishWebProjects: false 35 | projects: '**/*.csproj' 36 | arguments: '--configuration Release --output $(Build.ArtifactStagingDirectory)' 37 | workingDirectory: src 38 | 39 | - task: PublishBuildArtifacts@1 40 | displayName: 'Publish Artifact: drop' 41 | condition: succeededOrFailed() 42 | 43 | - task: PublishBuildArtifacts@1 44 | displayName: 'Publish Artifact: drop azuredeploy.json' 45 | inputs: 46 | PathtoPublish: config/azuredeploy.json 47 | condition: succeededOrFailed() 48 | 49 | - task: PublishBuildArtifacts@1 50 | displayName: 'Publish Artifact: drop azuredeploy.parameters.json' 51 | inputs: 52 | PathtoPublish: config/azuredeploy.parameters.json 53 | condition: succeededOrFailed() 54 | 55 | - task: PublishBuildArtifacts@1 56 | displayName: 'Publish Artifact: drop storage.json' 57 | inputs: 58 | PathtoPublish: config/storage.json 59 | condition: succeededOrFailed() 60 | 61 | - task: PublishBuildArtifacts@1 62 | displayName: 'Publish Artifact: drop StorageDeploy.ps1' 63 | inputs: 64 | PathtoPublish: build/StorageDeploy.ps1 65 | condition: succeededOrFailed() 66 | 67 | - task: PublishBuildArtifacts@1 68 | displayName: 'Publish Artifact: drop Subscriptions.json' 69 | inputs: 70 | PathtoPublish: config/Subscriptions.json 71 | condition: succeededOrFailed() 72 | -------------------------------------------------------------------------------- /build/StorageDeploy.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param ( 3 | [Parameter()] 4 | [string] 5 | $AzureOutput = $env:AzureOutput, 6 | 7 | [Parameter()] 8 | $ConfigFile = (Resolve-Path "storage.json").Path 9 | ) 10 | end { 11 | $AzureOutput 12 | 13 | 'Processing AzureOutput' 14 | $AzOutput = $AzureOutput | ConvertFrom-Json -ErrorAction 'Stop' 15 | $StorageAccount = $AzOutput.StorageAccount.value 16 | $ResourceGroup = $AzOutput.ResourceGroup.value 17 | 18 | 'Processing {0}' -f $ConfigFile 19 | $Config = Get-Content -raw -Path $ConfigFile | ConvertFrom-Json -ErrorAction Stop 20 | $Account = Get-AzureRmStorageAccount -Name $StorageAccount -ResourceGroupName $ResourceGroup 21 | $Context = $Account.Context 22 | 23 | foreach($queue in $Config.queues){ 24 | try { 25 | 'Adding queue {0}' -f $queue 26 | New-AzureStorageQueue -Name $queue -Context $Context -ErrorAction 'Stop' 27 | 'Added queue {0}' -f $queue 28 | } catch [Microsoft.WindowsAzure.Commands.Storage.Common.ResourceAlreadyExistException] { 29 | 'Queue {0} already exists.' -f $queue 30 | } catch { 31 | $PSCmdlet.ThrowTerminatingError($_) 32 | } 33 | } 34 | 35 | foreach($table in $Config.tables){ 36 | try { 37 | 'Adding table {0}' -f $table 38 | New-AzureStorageTable -Name $table -Context $Context -ErrorAction 'Stop' 39 | 'Added table {0}' -f $table 40 | } catch [Microsoft.WindowsAzure.Commands.Storage.Common.ResourceAlreadyExistException] { 41 | 'Table {0} already exists.' -f $table 42 | } catch { 43 | $PSCmdlet.ThrowTerminatingError($_) 44 | } 45 | } 46 | 47 | foreach ($container in $Config.containers) { 48 | try { 49 | 'Adding container {0}' -f $container 50 | New-AzureStorageContainer -Context $Context -Name $container -ErrorAction Stop 51 | 'Added container {0}' -f $container 52 | } catch [Microsoft.WindowsAzure.Commands.Storage.Common.ResourceAlreadyExistException] { 53 | 'Container {0} already exists' -f $container 54 | } catch { 55 | $PSCmdlet.ThrowTerminatingError($_) 56 | } 57 | } 58 | 59 | $QueueList = Get-AzureStorageQueue -Context $Context | ForEach-Object -MemberName Name 60 | $RemoveQueues = [System.Linq.Enumerable]::Except([string[]]$QueueList, [string[]]$Config.queues) 61 | foreach($queue in $RemoveQueues) { 62 | try { 63 | 'Removing queue {0}' -f $queue 64 | Remove-AzureStorageQueue -Name $queue -Context $Context -Force -ErrorAction 'stop' 65 | 'Removed queue {0}' -f $queue 66 | } catch [Microsoft.WindowsAzure.Commands.Storage.Common.ResourceNotFoundException] { 67 | 'Queue {0} already removed.' -f $queue 68 | } catch { 69 | $PSCmdlet.ThrowTerminatingError($_) 70 | } 71 | } 72 | 73 | $TableList = Get-AzureStorageTable -Context $Context | ForEach-Object -MemberName Name 74 | $RemoveTables = [System.Linq.Enumerable]::Except([string[]]$TableList, [string[]]$Config.tables) 75 | foreach($table in $RemoveTables) { 76 | try { 77 | 'Removing table {0}' -f $table 78 | Remove-AzureStorageTable -Name $table -Context $Context -Force -ErrorAction 'stop' 79 | 'Removed table {0}' -f $table 80 | } catch [Microsoft.WindowsAzure.Commands.Storage.Common.ResourceNotFoundException] { 81 | 'Table {0} already removed.' -f $table 82 | } catch { 83 | $PSCmdlet.ThrowTerminatingError($_) 84 | } 85 | } 86 | 87 | $ContainerList = Get-AzureStorageContainer -Context $Context | ForEach-Object -MemberName Name 88 | $RemoveContainers = [System.Linq.Enumerable]::Except([string[]]$ContainerList, [string[]]$Config.containers) 89 | foreach($container in $RemoveContainers) { 90 | try { 91 | 'Removing container {0}' -f $container 92 | Remove-AzureStorageContainer -Context $Context -Name $container -Force -ErrorAction 'stop' 93 | 'Removed container {0}' -f $container 94 | } catch [Microsoft.WindowsAzure.Commands.Storage.Common.ResourceNotFoundException] { 95 | 'container {0} already removed.' -f $container 96 | } catch { 97 | $PSCmdlet.ThrowTerminatingError($_) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /build/SubscriptionsUpdate.yaml: -------------------------------------------------------------------------------- 1 | pool: 2 | name: Hosted VS2017 3 | demands: azureps 4 | 5 | trigger: 6 | branches: 7 | include: 8 | - master 9 | paths: 10 | include: 11 | - config/Subscriptions.json 12 | 13 | steps: 14 | - task: PowerShell@2 15 | displayName: 'Invoke-TwitchSubscriptionRegistration' 16 | inputs: 17 | targetType: filePath 18 | filePath: './scripts/Invoke-TwitchSubscriptionRegistration.ps1' 19 | env: 20 | FunctionCode: $(FunctionCode) 21 | 22 | - task: AzureFileCopy@2 23 | displayName: 'Copy Subscriptions.json' 24 | inputs: 25 | SourcePath: config/Subscriptions.json 26 | azureSubscription: 'Borrowed Sub' 27 | Destination: AzureBlob 28 | storage: twitchstreib5ymu5bwh4su 29 | ContainerName: twitchsubscriptions 30 | -------------------------------------------------------------------------------- /config/Subscriptions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "twitchname": "corbob", 4 | "twittername": "CoryKnox", 5 | "discordname": "227552571450064897" 6 | }, 7 | { 8 | "twitchname": "halbaradkenafin", 9 | "twittername": "halbaradkenafin", 10 | "discordname": "323898314149986305" 11 | }, 12 | { 13 | "twitchname": "MrThomasRayner", 14 | "twittername": "MrThomasRayner", 15 | "discordname": "497792454393593876" 16 | }, 17 | { 18 | "twitchname": "steviecoaster", 19 | "twittername": "steviecoaster", 20 | "discordname": "142644056391024640" 21 | }, 22 | { 23 | "twitchname": "PowerShellTeam", 24 | "twittername": "PowerShell_Team", 25 | "discordname": "" 26 | }, 27 | { 28 | "twitchname": "tylerleonhardt", 29 | "twittername": "TylerLeonhardt", 30 | "discordname": "342871122469060608" 31 | }, 32 | { 33 | "twitchname": "potatoqualitee", 34 | "twittername": "cl", 35 | "discordname": "" 36 | }, 37 | { 38 | "twitchname": "kevinmarquette", 39 | "twittername": "kevinmarquette", 40 | "discordname": "" 41 | }, 42 | { 43 | "twitchname": "glennsarti", 44 | "twittername": "GlennSarti", 45 | "discordname": "" 46 | }, 47 | { 48 | "twitchname": "veronicageek", 49 | "twittername": "veronicageek", 50 | "discordname": "" 51 | }, 52 | { 53 | "twitchname": "powershelllive", 54 | "twittername": "PowerShellLive", 55 | "discordname": "" 56 | }, 57 | { 58 | "twitchname": "guestconfig", 59 | "twittername": "migreene", 60 | "discordname": "" 61 | }, 62 | { 63 | "twitchname": "theposhwolf", 64 | "twittername": "ThePoShWolf", 65 | "discordname": "" 66 | }, 67 | { 68 | "twitchname": "pwshdooduk", 69 | "twittername": "ryanyates1990", 70 | "discordname": "494524121422757888" 71 | }, 72 | { 73 | "twitchname": "brettmillerit", 74 | "twittername": "BrettMiller_IT", 75 | "discordname": "446672796693561357" 76 | }, 77 | { 78 | "twitchname": "charlie_schmidt", 79 | "twittername": "", 80 | "discordname": "252617743612903424" 81 | }, 82 | { 83 | "twitchname": "rchaganti", 84 | "twittername": "ravikanth", 85 | "discordname": "" 86 | }, 87 | { 88 | "twitchname": "poshcode", 89 | "twittername": "Jaykul", 90 | "discordname": "176528591041986561" 91 | }, 92 | { 93 | "twitchname": "scrthq", 94 | "twittername": "scrthq", 95 | "discordname": "446696540501966848" 96 | }, 97 | { 98 | "twitchname": "PowerShellMichael", 99 | "twittername": "PowerShellMich1", 100 | "discordname": "347997436054208512" 101 | }, 102 | { 103 | "twitchname": "tinkerbo", 104 | "twittername": "ti83", 105 | "discordname": "306133067045535754" 106 | }, 107 | { 108 | "twitchname": "jwtruher", 109 | "twittername": "jwtruher", 110 | "discordname": "" 111 | }, 112 | { 113 | "twitchname": "brispug", 114 | "twittername": "", 115 | "discordname": "" 116 | }, 117 | { 118 | "twitchname": "windosnz", 119 | "twittername": "WindosNZ", 120 | "discordname": "" 121 | }, 122 | { 123 | "twitchname": "bridgeconf", 124 | "twittername": "", 125 | "discordname": "" 126 | }, 127 | { 128 | "twitchname": "SimonWse", 129 | "twittername": "SimonWahlin", 130 | "discordname": "337663594957373455" 131 | }, 132 | { 133 | "twitchname": "TechDufus", 134 | "twittername": "TechDufus", 135 | "discordname": "430511869090856971" 136 | }, 137 | { 138 | "twitchname": "MrPowerShell", 139 | "twittername": "jamesbru", 140 | "discordname": "634881726824316940" 141 | } 142 | ] 143 | -------------------------------------------------------------------------------- /config/deployCode.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory)] 3 | $SystemName 4 | ) 5 | 6 | Push-Location '..\src' 7 | func azure functionapp publish $SystemName 8 | Pop-Location -------------------------------------------------------------------------------- /config/deployInfrastructure.ps1: -------------------------------------------------------------------------------- 1 | [cmdletbinding()] 2 | param( 3 | $RGName = 'TwitchStreamNotify', 4 | $ParametersPath = (Resolve-Path "main.parameters.json").Path, 5 | $StorageConfigPath = (Resolve-Path "storage.json").Path 6 | ) 7 | 8 | $ArmParameters = Get-Content -Path $ParametersPath | ConvertFrom-Json 9 | $Location = $ArmParameters.parameters.location.value 10 | 11 | $null = az group create --location $Location --name $RGName 12 | 13 | $DeploymentResult = az deployment group create -g $RGName -f .\main.json -p $ParametersPath --only-show-errors | ConvertFrom-Json 14 | if($DeploymentResult.properties.provisioningState -ne 'Succeeded') { 15 | if($DeploymentResult.properties.error) { 16 | $Message = $DeploymentResult.properties.error 17 | } 18 | else { 19 | $Message = 'Failed to deploy' 20 | } 21 | throw $Message 22 | } 23 | $StorageAccount = $DeploymentResult.properties.outputs.storageAccountName.value 24 | 25 | $Config = Get-Content -Raw -Path $storageConfigPath | ConvertFrom-Json -ErrorAction Stop 26 | foreach($queue in $Config.queues){ 27 | 'Adding queue {0}' -f $queue 28 | $created = az storage queue create --account-name $StorageAccount --name $queue -o tsv --only-show-errors 29 | if($created -eq 'True') { 30 | 'Added queue {0}' -f $queue 31 | } 32 | elseif ($created -eq 'False') { 33 | 'Queue {0} already exists.' -f $queue 34 | } 35 | else { 36 | $PSCmdlet.ThrowTerminatingError($created) 37 | } 38 | } 39 | 40 | foreach($table in $Config.tables){ 41 | 'Adding table {0}' -f $table 42 | $created = az storage table create --account-name $StorageAccount --name $table -o tsv --only-show-errors 43 | if($created -eq 'True') { 44 | 'Added table {0}' -f $table 45 | } 46 | elseif ($created -eq 'False') { 47 | 'Table {0} already exists.' -f $table 48 | } 49 | else { 50 | $PSCmdlet.ThrowTerminatingError($created) 51 | } 52 | } 53 | 54 | foreach ($container in $Config.containers) { 55 | 'Adding container {0}' -f $container 56 | $created = az storage container create --account-name $StorageAccount --name $container -o tsv --only-show-errors 57 | if($created -eq 'True') { 58 | 'Added container {0}' -f $container 59 | } 60 | elseif ($created -eq 'False') { 61 | 'Container {0} already exists' -f $container 62 | } 63 | else { 64 | $PSCmdlet.ThrowTerminatingError($created) 65 | } 66 | } 67 | 68 | $QueueList = az storage queue list --account-name $StorageAccount --query '[].name' -o tsv --only-show-errors 69 | $RemoveQueues = [System.Linq.Enumerable]::Except([string[]]$QueueList, [string[]]$Config.queues) 70 | foreach($queue in $RemoveQueues) { 71 | 'Removing queue {0}' -f $queue 72 | $removed = az storage queue delete --account-name $StorageAccount --name $queue -o tsv --only-show-errors 73 | if($removed -eq 'True') { 74 | 'Removed queue {0}' -f $queue 75 | } 76 | elseif ($removed -eq 'False') { 77 | 'Queue {0} already removed.' -f $queue 78 | } 79 | else { 80 | $PSCmdlet.ThrowTerminatingError($removed) 81 | } 82 | } 83 | 84 | $TableList = az storage table list --account-name $StorageAccount --query '[].name' -o tsv --only-show-errors 85 | $RemoveTables = [System.Linq.Enumerable]::Except([string[]]$TableList, [string[]]$Config.tables) 86 | foreach($table in $RemoveTables) { 87 | 'Removing table {0}' -f $table 88 | $removed = az storage table delete --account-name $StorageAccount --name $table -o tsv --only-show-errors 89 | if($removed -eq 'True') { 90 | 'Removed table {0}' -f $table 91 | } 92 | elseif ($removed -eq 'False') { 93 | 'Table {0} already removed.' -f $table 94 | } 95 | else { 96 | $PSCmdlet.ThrowTerminatingError($removed) 97 | } 98 | } 99 | 100 | $ContainerList = az storage container list --account-name $StorageAccount --query '[].name' -o tsv --only-show-errors 101 | $RemoveContainers = [System.Linq.Enumerable]::Except([string[]]$ContainerList, [string[]]$Config.containers) 102 | foreach($container in $RemoveContainers) { 103 | 'Removing container {0}' -f $container 104 | $removed = az storage container delete --account-name $StorageAccount --name $container -o tsv --only-show-errors 105 | if($removed -eq 'True') { 106 | 'Removed container {0}' -f $container 107 | } 108 | elseif ($removed -eq 'False') { 109 | 'Container {0} already removed.' -f $container 110 | } 111 | else { 112 | $PSCmdlet.ThrowTerminatingError($removed) 113 | } 114 | } 115 | 116 | -------------------------------------------------------------------------------- /config/main.bicep: -------------------------------------------------------------------------------- 1 | param systemName string { 2 | metadata: { 3 | description: 'Name that will be common for all resources created.' 4 | } 5 | } 6 | 7 | param storageAccountSku string { 8 | default: 'Standard_LRS' 9 | allowed: [ 10 | 'Standard_LRS' 11 | 'Standard_GRS' 12 | 'Standard_RAGRS' 13 | 'Standard_ZRS' 14 | 'Premium_LRS' 15 | 'Premium_ZRS' 16 | 'Standard_GZRS' 17 | 'Standard_RAGZRS' 18 | ] 19 | metadata: { 20 | description: 'Storage account SKU' 21 | } 22 | } 23 | 24 | param runtime string { 25 | default: 'powershell' 26 | allowed: [ 27 | 'powershell' 28 | 'dotnet' 29 | ] 30 | } 31 | 32 | param appSettings object { 33 | default: {} 34 | metadata: { 35 | description: 'Key-Value pairs representing custom app settings' 36 | } 37 | } 38 | 39 | param location string { 40 | default: resourceGroup().location 41 | metadata: { 42 | description: 'Location for all resources created' 43 | } 44 | } 45 | 46 | var functionAppName = systemName 47 | var hostingPlanName = '${systemName}-plan' 48 | var logAnalyticsName = '${systemName}-log' 49 | var applicationInsightsName = '${systemName}-appin' 50 | var systemNameNoDash = replace(systemName,'-','') 51 | var uniqueStringRg = uniqueString(resourceGroup().id) 52 | var storageAccountName = toLower('${take(systemNameNoDash,17)}${take(uniqueStringRg,5)}sa') 53 | var keyVaultName = '${take(systemNameNoDash,17)}${take(uniqueStringRg,5)}kv' 54 | var storageConnectionStringName = '${systemName}-connectionstring' 55 | var runtimeSettingsTable = { 56 | powershell: { 57 | FUNCTIONS_WORKER_RUNTIME: 'powershell' 58 | FUNCTIONS_WORKER_RUNTIME_VERSION: '~7' 59 | FUNCTIONS_WORKER_PROCESS_COUNT: 10 60 | PSWorkerInProcConcurrencyUpperBound: 1 61 | } 62 | dotnet: { 63 | FUNCTIONS_WORKER_RUNTIME: 'dotnet' 64 | } 65 | } 66 | var runtimeSettings = runtime == 'powershell' ? runtimeSettingsTable.powershell : runtime == 'dotnet' ? runtimeSettingsTable.dotnet : {} 67 | 68 | resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2020-03-01-preview' = { 69 | name: logAnalyticsName 70 | location: location 71 | properties: { 72 | sku: { 73 | name: 'PerGB2018' 74 | } 75 | retentionInDays: 90 76 | } 77 | } 78 | 79 | resource appInsights 'Microsoft.insights/components@2020-02-02-preview' = { 80 | name: applicationInsightsName 81 | location: location 82 | kind: 'web' 83 | tags: { 84 | 'hidden-link:${resourceId('Microsoft.Web/sites',functionAppName)}': 'Resource' 85 | } 86 | properties: { 87 | Application_Type: 'web' 88 | WorkspaceResourceId: logAnalytics.id 89 | } 90 | } 91 | 92 | resource storageAccount 'Microsoft.Storage/storageAccounts@2019-06-01' = { 93 | name: storageAccountName 94 | location: location 95 | sku: { 96 | name: storageAccountSku 97 | } 98 | kind: 'StorageV2' 99 | properties: { 100 | supportsHttpsTrafficOnly: true 101 | encryption: { 102 | services: { 103 | file: { 104 | keyType: 'Account' 105 | enabled: true 106 | } 107 | blob: { 108 | keyType: 'Account' 109 | enabled: true 110 | } 111 | } 112 | keySource: 'Microsoft.Storage' 113 | } 114 | } 115 | } 116 | 117 | resource storageAccountBlobService 'Microsoft.Storage/storageAccounts/blobServices@2019-06-01' = { 118 | name: '${storageAccount.name}/default' 119 | properties: { 120 | deleteRetentionPolicy: { 121 | enabled: false 122 | } 123 | } 124 | } 125 | 126 | resource storageAccountFileService 'Microsoft.Storage/storageAccounts/fileServices@2019-06-01' = { 127 | name: '${storageAccount.name}/default' 128 | properties: { 129 | shareDeleteRetentionPolicy: { 130 | enabled: false 131 | } 132 | } 133 | } 134 | 135 | resource hostingPlan 'Microsoft.Web/serverfarms@2020-06-01' = { 136 | name: hostingPlanName 137 | location: location 138 | sku: { 139 | name: 'Y1' 140 | } 141 | } 142 | 143 | resource functionApp 'Microsoft.Web/sites@2020-06-01' = { 144 | name: functionAppName 145 | location: location 146 | kind: 'functionapp' 147 | identity: { 148 | type: 'SystemAssigned' 149 | } 150 | properties: { 151 | enabled: true 152 | httpsOnly: true 153 | serverFarmId: hostingPlan.id 154 | siteConfig: { 155 | ftpsState: 'Disabled' 156 | minTlsVersion: '1.2' 157 | powerShellVersion: '~7' 158 | scmType: 'None' 159 | } 160 | containerSize: 1536 // not used any more, but portal complains without it 161 | } 162 | } 163 | 164 | resource funcitonAppDiagLogAnalytics 'Microsoft.Web/sites/providers/diagnosticSettings@2017-05-01-preview' = { 165 | name: '${functionApp.name}/Microsoft.Insights/logAnalyticsAudit' 166 | location: location 167 | properties: { 168 | workspaceId: logAnalytics.id 169 | logs: [ 170 | { 171 | category: 'FunctionAppLogs' 172 | enabled: true 173 | retentionPolicy: { 174 | enabled: true 175 | days: 90 176 | } 177 | } 178 | ] 179 | } 180 | } 181 | 182 | resource keyvault 'Microsoft.KeyVault/vaults@2019-09-01' = { 183 | name: keyVaultName 184 | location: location 185 | properties: { 186 | enabledForDeployment: false 187 | enabledForTemplateDeployment: false 188 | enabledForDiskEncryption: false 189 | tenantId: subscription().tenantId 190 | accessPolicies: [ 191 | { 192 | // delegate secrets access to function app 193 | // tenantId: reference(functionApp.id,'2020-06-01','Full').identity.tenantId 194 | tenantId: functionApp.identity.tenantId 195 | objectId: functionApp.identity.principalId 196 | permissions: { 197 | secrets: [ 198 | 'get' 199 | 'list' 200 | 'set' 201 | ] 202 | } 203 | } 204 | ] 205 | sku: { 206 | name: 'standard' 207 | family: 'A' 208 | } 209 | } 210 | } 211 | 212 | resource keyvaultDiagStorage 'Microsoft.KeyVault/vaults/providers/diagnosticSettings@2017-05-01-preview' = { 213 | name: '${keyvault.name}/Microsoft.Insights/service' 214 | location: location 215 | properties: { 216 | storageAccountId: storageAccount.id 217 | logs: [ 218 | { 219 | category: 'AuditEvent' 220 | enabled: true 221 | retentionPolicy: { 222 | enabled: false 223 | days: 0 224 | } 225 | } 226 | ] 227 | } 228 | } 229 | 230 | resource keyvaultDiagLogAnalytics 'Microsoft.KeyVault/vaults/providers/diagnosticSettings@2017-05-01-preview' = { 231 | name: '${keyvault.name}/Microsoft.Insights/logAnalyticsAudit' 232 | location: location 233 | properties: { 234 | workspaceId: logAnalytics.id 235 | logs: [ 236 | { 237 | category: 'AuditEvent' 238 | enabled: true 239 | retentionPolicy: { 240 | enabled: true 241 | days: 90 242 | } 243 | } 244 | ] 245 | } 246 | } 247 | 248 | resource secretStorageConnectionString 'Microsoft.KeyVault/vaults/secrets@2019-09-01' = { 249 | name: '${keyvault.name}/${storageConnectionStringName}' 250 | properties: { 251 | value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${listKeys(storageAccount.id,'2019-06-01').keys[0].value}' 252 | } 253 | } 254 | 255 | resource functionAppSettings 'Microsoft.Web/sites/config@2020-06-01' = { 256 | name: '${functionApp.name}/appsettings' 257 | properties: union(runtimeSettings,appSettings,{ 258 | AzureWebJobsStorage: '@Microsoft.KeyVault(SecretUri=${reference(storageConnectionStringName).secretUriWithVersion})' 259 | WEBSITE_CONTENTSHARE: toLower(functionApp.name) 260 | WEBSITE_CONTENTAZUREFILECONNECTIONSTRING: '@Microsoft.KeyVault(SecretUri=${reference(storageConnectionStringName).secretUriWithVersion})' 261 | APPINSIGHTS_INSTRUMENTATIONKEY: reference(appInsights.id,'2020-02-02-preview').InstrumentationKey 262 | APPLICATIONINSIGHTS_CONNECTION_STRING: reference(appInsights.id,'2020-02-02-preview').ConnectionString 263 | FUNCTIONS_EXTENSION_VERSION: '~3' 264 | AzureWebJobsSecretStorageKeyVaultName: keyvault.name 265 | AzureWebJobsSecretStorageType: 'keyvault' 266 | AzureWebJobsSecretStorageKeyVaultConnectionString: '' 267 | AzureWebJobsDisableHomepage: true 268 | FUNCTIONS_APP_EDIT_MODE: 'readonly' 269 | WEBSITE_RUN_FROM_PACKAGE: '1' 270 | }) 271 | } 272 | 273 | output FunctionAppName string = functionApp.name 274 | output PrincipalIdRef string = reference(functionApp.id,'2020-06-01','Full').identity.principalId 275 | output PrincipalTenantId string = functionApp.identity.tenantId 276 | output PrincipalId string = functionApp.identity.principalId 277 | output StorageAccountName string = storageAccountName 278 | output KeyVaultName string = keyVaultName -------------------------------------------------------------------------------- /config/main.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "systemName": { 6 | "type": "string", 7 | "metadata": { 8 | "description": "Name that will be common for all resources created." 9 | } 10 | }, 11 | "storageAccountSku": { 12 | "type": "string", 13 | "metadata": { 14 | "description": "Storage account SKU" 15 | }, 16 | "defaultValue": "Standard_LRS", 17 | "allowedValues": [ 18 | "Standard_LRS", 19 | "Standard_GRS", 20 | "Standard_RAGRS", 21 | "Standard_ZRS", 22 | "Premium_LRS", 23 | "Premium_ZRS", 24 | "Standard_GZRS", 25 | "Standard_RAGZRS" 26 | ] 27 | }, 28 | "runtime": { 29 | "type": "string", 30 | "defaultValue": "powershell", 31 | "allowedValues": [ 32 | "powershell", 33 | "dotnet" 34 | ] 35 | }, 36 | "appSettings": { 37 | "type": "object", 38 | "metadata": { 39 | "description": "Key-Value pairs representing custom app settings" 40 | }, 41 | "defaultValue": {} 42 | }, 43 | "location": { 44 | "type": "string", 45 | "metadata": { 46 | "description": "Location for all resources created" 47 | }, 48 | "defaultValue": "[resourceGroup().location]" 49 | } 50 | }, 51 | "functions": [], 52 | "variables": { 53 | "functionAppName": "[parameters('systemName')]", 54 | "hostingPlanName": "[format('{0}-plan', parameters('systemName'))]", 55 | "logAnalyticsName": "[format('{0}-log', parameters('systemName'))]", 56 | "applicationInsightsName": "[format('{0}-appin', parameters('systemName'))]", 57 | "systemNameNoDash": "[replace(parameters('systemName'), '-', '')]", 58 | "uniqueStringRg": "[uniqueString(resourceGroup().id)]", 59 | "storageAccountName": "[toLower(format('{0}{1}sa', take(variables('systemNameNoDash'), 17), take(variables('uniqueStringRg'), 5)))]", 60 | "keyVaultName": "[format('{0}{1}kv', take(variables('systemNameNoDash'), 17), take(variables('uniqueStringRg'), 5))]", 61 | "storageConnectionStringName": "[format('{0}-connectionstring', parameters('systemName'))]", 62 | "runtimeSettingsTable": { 63 | "powershell": { 64 | "FUNCTIONS_WORKER_RUNTIME": "powershell", 65 | "FUNCTIONS_WORKER_RUNTIME_VERSION": "~7", 66 | "FUNCTIONS_WORKER_PROCESS_COUNT": 10, 67 | "PSWorkerInProcConcurrencyUpperBound": 1 68 | }, 69 | "dotnet": { 70 | "FUNCTIONS_WORKER_RUNTIME": "dotnet" 71 | } 72 | }, 73 | "runtimeSettings": "[if(equals(parameters('runtime'), 'powershell'), variables('runtimeSettingsTable').powershell, if(equals(parameters('runtime'), 'dotnet'), variables('runtimeSettingsTable').dotnet, createObject()))]" 74 | }, 75 | "resources": [ 76 | { 77 | "type": "Microsoft.OperationalInsights/workspaces", 78 | "apiVersion": "2020-03-01-preview", 79 | "name": "[variables('logAnalyticsName')]", 80 | "location": "[parameters('location')]", 81 | "properties": { 82 | "sku": { 83 | "name": "PerGB2018" 84 | }, 85 | "retentionInDays": 90 86 | } 87 | }, 88 | { 89 | "type": "Microsoft.Insights/components", 90 | "apiVersion": "2020-02-02-preview", 91 | "name": "[variables('applicationInsightsName')]", 92 | "location": "[parameters('location')]", 93 | "kind": "web", 94 | "tags": { 95 | "[format('hidden-link:{0}', resourceId('Microsoft.Web/sites', variables('functionAppName')))]": "Resource" 96 | }, 97 | "properties": { 98 | "Application_Type": "web", 99 | "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', variables('logAnalyticsName'))]" 100 | }, 101 | "dependsOn": [ 102 | "[resourceId('Microsoft.OperationalInsights/workspaces', variables('logAnalyticsName'))]" 103 | ] 104 | }, 105 | { 106 | "type": "Microsoft.Storage/storageAccounts", 107 | "apiVersion": "2019-06-01", 108 | "name": "[variables('storageAccountName')]", 109 | "location": "[parameters('location')]", 110 | "sku": { 111 | "name": "[parameters('storageAccountSku')]" 112 | }, 113 | "kind": "StorageV2", 114 | "properties": { 115 | "supportsHttpsTrafficOnly": true, 116 | "encryption": { 117 | "services": { 118 | "file": { 119 | "keyType": "Account", 120 | "enabled": true 121 | }, 122 | "blob": { 123 | "keyType": "Account", 124 | "enabled": true 125 | } 126 | }, 127 | "keySource": "Microsoft.Storage" 128 | } 129 | } 130 | }, 131 | { 132 | "type": "Microsoft.Storage/storageAccounts/blobServices", 133 | "apiVersion": "2019-06-01", 134 | "name": "[format('{0}/default', variables('storageAccountName'))]", 135 | "properties": { 136 | "deleteRetentionPolicy": { 137 | "enabled": false 138 | } 139 | }, 140 | "dependsOn": [ 141 | "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]" 142 | ] 143 | }, 144 | { 145 | "type": "Microsoft.Storage/storageAccounts/fileServices", 146 | "apiVersion": "2019-06-01", 147 | "name": "[format('{0}/default', variables('storageAccountName'))]", 148 | "properties": { 149 | "shareDeleteRetentionPolicy": { 150 | "enabled": false 151 | } 152 | }, 153 | "dependsOn": [ 154 | "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]" 155 | ] 156 | }, 157 | { 158 | "type": "Microsoft.Web/serverfarms", 159 | "apiVersion": "2020-06-01", 160 | "name": "[variables('hostingPlanName')]", 161 | "location": "[parameters('location')]", 162 | "sku": { 163 | "name": "Y1" 164 | } 165 | }, 166 | { 167 | "type": "Microsoft.Web/sites", 168 | "apiVersion": "2020-06-01", 169 | "name": "[variables('functionAppName')]", 170 | "location": "[parameters('location')]", 171 | "kind": "functionapp", 172 | "identity": { 173 | "type": "SystemAssigned" 174 | }, 175 | "properties": { 176 | "enabled": true, 177 | "httpsOnly": true, 178 | "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]", 179 | "siteConfig": { 180 | "ftpsState": "Disabled", 181 | "minTlsVersion": "1.2", 182 | "powerShellVersion": "~7", 183 | "scmType": "None" 184 | }, 185 | "containerSize": 1536 186 | }, 187 | "dependsOn": [ 188 | "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]" 189 | ] 190 | }, 191 | { 192 | "type": "Microsoft.Web/sites/providers/diagnosticSettings", 193 | "apiVersion": "2017-05-01-preview", 194 | "name": "[format('{0}/Microsoft.Insights/logAnalyticsAudit', variables('functionAppName'))]", 195 | "location": "[parameters('location')]", 196 | "properties": { 197 | "workspaceId": "[resourceId('Microsoft.OperationalInsights/workspaces', variables('logAnalyticsName'))]", 198 | "logs": [ 199 | { 200 | "category": "FunctionAppLogs", 201 | "enabled": true, 202 | "retentionPolicy": { 203 | "enabled": true, 204 | "days": 90 205 | } 206 | } 207 | ] 208 | }, 209 | "dependsOn": [ 210 | "[resourceId('Microsoft.Web/sites', variables('functionAppName'))]", 211 | "[resourceId('Microsoft.OperationalInsights/workspaces', variables('logAnalyticsName'))]" 212 | ] 213 | }, 214 | { 215 | "type": "Microsoft.KeyVault/vaults", 216 | "apiVersion": "2019-09-01", 217 | "name": "[variables('keyVaultName')]", 218 | "location": "[parameters('location')]", 219 | "properties": { 220 | "enabledForDeployment": false, 221 | "enabledForTemplateDeployment": false, 222 | "enabledForDiskEncryption": false, 223 | "tenantId": "[subscription().tenantId]", 224 | "accessPolicies": [ 225 | { 226 | "tenantId": "[reference(resourceId('Microsoft.Web/sites', variables('functionAppName')), '2020-06-01', 'full').identity.tenantId]", 227 | "objectId": "[reference(resourceId('Microsoft.Web/sites', variables('functionAppName')), '2020-06-01', 'full').identity.principalId]", 228 | "permissions": { 229 | "secrets": [ 230 | "get", 231 | "list", 232 | "set" 233 | ] 234 | } 235 | } 236 | ], 237 | "sku": { 238 | "name": "standard", 239 | "family": "A" 240 | } 241 | }, 242 | "dependsOn": [ 243 | "[resourceId('Microsoft.Web/sites', variables('functionAppName'))]" 244 | ] 245 | }, 246 | { 247 | "type": "Microsoft.KeyVault/vaults/providers/diagnosticSettings", 248 | "apiVersion": "2017-05-01-preview", 249 | "name": "[format('{0}/Microsoft.Insights/service', variables('keyVaultName'))]", 250 | "location": "[parameters('location')]", 251 | "properties": { 252 | "storageAccountId": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]", 253 | "logs": [ 254 | { 255 | "category": "AuditEvent", 256 | "enabled": true, 257 | "retentionPolicy": { 258 | "enabled": false, 259 | "days": 0 260 | } 261 | } 262 | ] 263 | }, 264 | "dependsOn": [ 265 | "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", 266 | "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]" 267 | ] 268 | }, 269 | { 270 | "type": "Microsoft.KeyVault/vaults/providers/diagnosticSettings", 271 | "apiVersion": "2017-05-01-preview", 272 | "name": "[format('{0}/Microsoft.Insights/logAnalyticsAudit', variables('keyVaultName'))]", 273 | "location": "[parameters('location')]", 274 | "properties": { 275 | "workspaceId": "[resourceId('Microsoft.OperationalInsights/workspaces', variables('logAnalyticsName'))]", 276 | "logs": [ 277 | { 278 | "category": "AuditEvent", 279 | "enabled": true, 280 | "retentionPolicy": { 281 | "enabled": true, 282 | "days": 90 283 | } 284 | } 285 | ] 286 | }, 287 | "dependsOn": [ 288 | "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", 289 | "[resourceId('Microsoft.OperationalInsights/workspaces', variables('logAnalyticsName'))]" 290 | ] 291 | }, 292 | { 293 | "type": "Microsoft.KeyVault/vaults/secrets", 294 | "apiVersion": "2019-09-01", 295 | "name": "[format('{0}/{1}', variables('keyVaultName'), variables('storageConnectionStringName'))]", 296 | "properties": { 297 | "value": "[format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1}', variables('storageAccountName'), listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2019-06-01').keys[0].value)]" 298 | }, 299 | "dependsOn": [ 300 | "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]", 301 | "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]" 302 | ] 303 | }, 304 | { 305 | "type": "Microsoft.Web/sites/config", 306 | "apiVersion": "2020-06-01", 307 | "name": "[format('{0}/appsettings', variables('functionAppName'))]", 308 | "properties": "[union(variables('runtimeSettings'), parameters('appSettings'), createObject('AzureWebJobsStorage', format('@Microsoft.KeyVault(SecretUri={0})', reference(variables('storageConnectionStringName')).secretUriWithVersion), 'WEBSITE_CONTENTSHARE', toLower(variables('functionAppName')), 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', format('@Microsoft.KeyVault(SecretUri={0})', reference(variables('storageConnectionStringName')).secretUriWithVersion), 'APPINSIGHTS_INSTRUMENTATIONKEY', reference(resourceId('Microsoft.Insights/components', variables('applicationInsightsName')), '2020-02-02-preview').InstrumentationKey, 'APPLICATIONINSIGHTS_CONNECTION_STRING', reference(resourceId('Microsoft.Insights/components', variables('applicationInsightsName')), '2020-02-02-preview').ConnectionString, 'FUNCTIONS_EXTENSION_VERSION', '~3', 'AzureWebJobsSecretStorageKeyVaultName', variables('keyVaultName'), 'AzureWebJobsSecretStorageType', 'keyvault', 'AzureWebJobsSecretStorageKeyVaultConnectionString', '', 'AzureWebJobsDisableHomepage', true(), 'FUNCTIONS_APP_EDIT_MODE', 'readonly'))]", 309 | "dependsOn": [ 310 | "[resourceId('Microsoft.Insights/components', variables('applicationInsightsName'))]", 311 | "[resourceId('Microsoft.Web/sites', variables('functionAppName'))]", 312 | "[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]" 313 | ] 314 | } 315 | ], 316 | "outputs": { 317 | "FunctionAppName": { 318 | "type": "string", 319 | "value": "[variables('functionAppName')]" 320 | }, 321 | "PrincipalIdRef": { 322 | "type": "string", 323 | "value": "[reference(resourceId('Microsoft.Web/sites', variables('functionAppName')), '2020-06-01', 'Full').identity.principalId]" 324 | }, 325 | "PrincipalTenantId": { 326 | "type": "string", 327 | "value": "[reference(resourceId('Microsoft.Web/sites', variables('functionAppName')), '2020-06-01', 'full').identity.tenantId]" 328 | }, 329 | "PrincipalId": { 330 | "type": "string", 331 | "value": "[reference(resourceId('Microsoft.Web/sites', variables('functionAppName')), '2020-06-01', 'full').identity.principalId]" 332 | }, 333 | "StorageAccountName": { 334 | "type": "string", 335 | "value": "[variables('storageAccountName')]" 336 | }, 337 | "KeyVaultName": { 338 | "type": "string", 339 | "value": "[variables('keyVaultName')]" 340 | } 341 | } 342 | } -------------------------------------------------------------------------------- /config/main.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "systemName": { 6 | "value": "TwitchStreamNotifications2" 7 | }, 8 | "storageAccountSku": { 9 | "value": "Standard_LRS" 10 | }, 11 | "runtime": { 12 | "value": "dotnet" 13 | }, 14 | "appSettings": { 15 | "value": { 16 | "DISABLE_NOTIFICATIONS": "false", 17 | "DiscordMessageTemplate": "{1} is streaming {3} live now: {0}", 18 | "DiscordScheduledEventMessageTemplate": "{1} has an upcoming streaming event in {2} at {3} titled '{4}' More info: {0}", 19 | "TwitterScheduledEventTweetTemplate": "{0} {1} has an upcoming streaming event in {2} at {3} titled '{4}'", 20 | "DiscordNotifications": "discordnotifications", 21 | "TwitchNotificationsTable": "twitchnotifications", 22 | "TwitchStreamActivity": "twitchstreamactivity", 23 | "TwitchSubscribeQueue": "twitchsubscribe", 24 | "TwitchUnsubscribeQueue": "twitchunsubscribe", 25 | "TwitchChannelEventLookupQueue": "twitchchanneleventlookup", 26 | "TwitchChannelEventProcessQueue": "twitchchanneleventprocess", 27 | "TwitterNotifications": "twitternotifications", 28 | "DiscordEventNotificationsQueue": "discordeventnotification", 29 | "TwitterEventNotificationsQueue": "twittereventnotification", 30 | "TwitterTweetTemplate": "{0} {1} is streaming {3} live as of {2}! #PowerShellLive", 31 | "TwitchSubscriptionBlob": "twitchsubscriptions/Subscriptions.json", 32 | "TwitchWebhookBaseUri": "https://twitchstreamnotifications2.azurewebsites.net/api/TwitchWebhookIngestion", 33 | "TwitchStreamStorage": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotiflszinkv.vault.azure.net/secrets/TwitchStreamNotifications2-connectionstring)", 34 | "DiscordWebhookUri": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotifyKV2.vault.azure.net/secrets/DiscordWebhookUri)", 35 | "TwitchClientId": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotifyKV2.vault.azure.net/secrets/TwitchClientId)", 36 | "TwitchClientRedirectUri": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotifyKV2.vault.azure.net/secrets/TwitchClientRedirectUri)", 37 | "TwitchClientSecret": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotifyKV2.vault.azure.net/secrets/TwitchClientSecret)", 38 | "TwitchSubscriptionsHashSecret": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotifyKV2.vault.azure.net/secrets/TwitchSubscriptionsHashSecret)", 39 | "TwitterAccessToken": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotifyKV2.vault.azure.net/secrets/TwitterAccessToken)", 40 | "TwitterAccessTokenSecret": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotifyKV2.vault.azure.net/secrets/TwitterAccessTokenSecret)", 41 | "TwitterConsumerKey": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotifyKV2.vault.azure.net/secrets/TwitterConsumerKey)", 42 | "TwitterConsumerSecret": "@Microsoft.KeyVault(SecretUri=https://TwitchStreamNotifyKV2.vault.azure.net/secrets/TwitterConsumerSecret)" 43 | } 44 | }, 45 | "location": { 46 | "value": "West US 2" 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /config/storage.json: -------------------------------------------------------------------------------- 1 | { 2 | "queues": [ 3 | "discordnotifications", 4 | "twitchstreamactivity", 5 | "twitchsubscribe", 6 | "twitchunsubscribe", 7 | "twitternotifications", 8 | "twitchchanneleventlookup", 9 | "twitchchanneleventprocess", 10 | "discordeventnotification", 11 | "twittereventnotification" 12 | ], 13 | "tables": [ 14 | "twitchnotifications" 15 | ], 16 | "containers": [ 17 | "azure-webjobs-hosts", 18 | "insights-logs-auditevent", 19 | "twitchsubscriptions" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /docs/Architecture.drawio: -------------------------------------------------------------------------------- 1 | 7Vtfd+K2Ev80OefeB3ws+Q/2Y0iW3Wx322zTdnufOMJWQF3b8pUFgf30lWwZbEsQCCakPZuHgEayPJqZ38xoJK6cm3T1nqF8/pnGOLmCdry6cm6vIAR2EIoPSVlXlMBzKsKMkbgi2VvCA/mO6ycVdUFiXChaReKUJpzkbWJEswxHvEVDjNGn9rBHmsQtQo5mWCM8RCjRqV9JzOeKCvxw2/EBk9lcvTqAw6ojRfVgtZJijmL61CA5766cG0Ypr76lqxucSOG15TLe0bthjOGMH/LAH24yGWfpxzxy7z59HH78Jfz5/QD4ijm+rleMYyEA1aSMz+mMZih5t6WOGF1kMZbT2qK1HfOJ0lwQgSD+hTlfK22iBaeCNOdponoFx2z9p3q+bPxPNiwXOjXhdtXsvl03W/eYkRRzzGriinA5G7BsL1TtakJPtbazyca60ejOpUtVCbqgCxYpEX2arP1Z/vtgQAM8mk5+yR/5cuAq60RshvkekXvVOCnmxguUzt5jKrhhazGA4QRxsmzbIVLmPNuM2zx6T4lgGdoKejAIqkcU8FzHscLQt+u/zoTV8tQcWysSXxpMbUmlbR1jZ8O3ZGeeP3yJnR1kG/t0/qxtBH3bRkuPxypNcb1EyUK96bcnwqP5A2cYpdeR4IDwtaZYVOSVJ34kK6m9pkZyaWAln97oyrsVFJSQWSYIkRCqlPSIpKVLHj3SjCvNAril35J0JlaTkKn4j74vGJarnOEMMyRWNH7glImBky8LvMBWsZztU9wSM45Xe0Wtel3bbsNJ8FS2n7ZRwVcuYN6IBx48XTlGdxNcAlAvR4CvI8C4rOGbQoCvIeAK+ongfxSJTKclfv//CxnJR1O6GhTkO8mE4V2LEVPKYswGgiwapQiF/OJ4029bEKflp1t+VkNSISeSqRH59lE1G0MxWRRVt9/ojmhCWUVms+l/oPTywm7F4m1oh9vvYPjfLcfi20x9lkuTwJOpEoraK/uAkyXmJELNRw0SkM9LEeCKEQAlg92XSU8i8P5uKSzpA8riRGBfvV9oqmKhzZYgV0Jvk4scZUY2jpKFkl/J+iNKSbKunkxpRsUbItwe0lidY/mm9dXsTZlhHRXPNfkNuM+IpvmCi0WOx4tM+HWaTa7zvOjPfQYOsMJhy4M6EFq2wYkGuhP17dPxvS7+gvzp8xd2v/wpyu9+9SGeGvD9lRFeAVvGuLtY16PwidKPTYW+LpnSACm8dvZsbwYcn9V0HO7LnHygO3mj2HvJeK8ZQ+vGAIWNnQmxF3pt+wOgvV/qjPd9eNJ4CIKOfVYc95pXB3tTNJN3/eEv/wn+0vM66abtvpqnNG/ggCb41/R2A7ve219wFxceuIvz+85hzQ4nsP1uTBX638TUeqKK3bPt7EOjBxLi/ply8iiyRYmONwHas+8R/VBTiOPYxiTnTDtFM3S9i0MXeBeGbl1Ofha7wO0bvKfpztbgdUuKSOwCf8BLhkUvuDC2oKYfHWxZfC1PIqSEE1QUJOogZWf52g+cowvYLdwNgw7uQv91cWfYEey182dh11B0nSK1FK1oJ4ZW32mnX359qvNMuVyfKGhP5A07E505OgOnB/tsWpQPe7Gohs0PQcvmoe0fbfPnt8/wrZmnZdsgrP86TrH2gEcbq55QOuCcCaWxAgwuUtneeVT0jHWfkJIYDgzNAnlbNfGab0NRXFWOVSXhtrnLd6IwBnHYR+lYJUAHlY7fQF50idqqWzuaVygXmG32ohgWgQrYTgfIzovuFhyEb6MI4IHw7n3D8aLqKAhB2/EP3b2xojvedfyO0ZxW7dwnUq3a+SZwjvJ8UmC2JBEu5PLv7yafUSYeSMW0kwfV0x/sw3Zm6biGCqH3ipB3LgJ5lUcKfLf2TmWqOjxPInno7R/juPPUBo9FOxx2jEdZyi60O/bwpPFQXQM8q3fYnZf8+w/rTziFj+Zf8XRO6be7bIaLqpz04yD+35osQge24/YrHiwZUXvZ4rRt+c6GUAeO5/Z8PSeKh16Ngn2HjpP0pl+dmC+mVjRHSYKFI7kkmtIiQhJCP+H1+YCzqTM3gOMAHTjeuYAzNCTDGHNN8DEq5lusLHhCMnyzuS1vtzWhyT1BU5zc04KUgaHRISVHIoG5zoAp5ZymjQHXakousTkq5iiXjKWrmfzBgPWEp4kAb2Hx6tRQqpgkyc2mhOCA22swlqouOKPfcN2T0Qz3kUWHrhX47eTG0TfPnoj8oa5c1z2TcqF+7qMpdibcYH64CDa/kSivLpV0e69ogG0Dy+5s83ygycY1FTr9MwkG6GavCaa2sdpnNOzbbJTP2nKJghGKvs3K0NOwzsfyr3ZE17WTk7DSPF7tq+ac51U+OJYhP84ciwjdPBIR1JglUgBBjRFH4kPS5W4yp/kiQWxQbzAHU4ayuBgsaSLjwtiTB1HjuKqMDQAMrFzkrJpt7Le150HT9HWGJKGm9a91/ZbBZ1wUyBBnxBJ4W+cGv9F2Moqk+b6usaQkjsv842lOOH7IqwuyT8KPaTlJ/6Lf7LwOwh/sIeoYr+7pm6ybOY6+lfm4yLDteJEn8lQa63n39krlJesE0K/Pl+p0DwRH1wka+aO9Of9StzCHJ9QZ/3m3MPVrlt3SQtgJBTtOo46+zum3f9/kqKP4XSUJz/ZPGg9ha/zJJYzdKjEUOJuXPYrfjHePL5Bfl4xM1G2PVqZtSD40S95999FvWxAIQ0M+ZkjGjo8+orn9AWalxO3PWJ13fwM=7Vtbc5s4FP41ntl9CAMSCPNoJ02303Ynm+xO2iePDCpWFxAVwrH761cCcbXj2GkcsxnnIaCjC0jnnO98OsIjeBmv3nOcLj6zgEQjYAarEbwaAWCZY09elGRdSjxkl4KQ06AUmY3gjv4kVU8tzWlAMi0rRYKxSNC0K/RZkhBfdGSYc/bQbfaNRUFHkOKQbAjufBxtSu9pIBZaaiGvqfiD0HChHz0GblkR46qxnkm2wAF7aInguxG85IyJ8i5eXZJILV61LtEUTJYJij7PQhPS+9v0T/Hxohzs+pAu9RQ4ScSzh/YexO1keX0Dgy/3HFjTH9fTr7qLucRRrtdrBFAkHzLNUpyoSYu1Xkn0I1cznfosYnwEJ7KSh/PfgClfTw5zKf8D02vuLfd3VYDTQmuJuPiGYxqty54xS5h8gk+6TbLCfFQDCxooXTVPlXehuv79QIW/uMvnmc9pKihLbklIM8Gxuq9efs6r9pVErks5o0oMOpMDOEtL6/tGV0Su2HQh4kgWLXmbMpqIwgyd6ci5khIc0TCRAl8qhMjFmNK4MMOpmoX2AQs08isah/INIjqX//HPnBOlKJ/FaS7kElxf54mv3n82SdPMyJZh/YpPKl0bx5JwQVYtk9dG8J6wmAi+lk10LbDGZRft0BCisvzQuAfSTRYtx0DaVrB2yLAeuTE6eaPt7gAbtOwNbZBA+rAuMi4WLGQJjt410ilneRIoRV0p+2nafGIs1Wr7ToRYa2XgXLCuUuWq8vUXWbgwDdOxK8lXNaLhVMWrlX5CWVq3SzeEU7kCSv9X5sEKy1jOfbJrWTRYYh4SsaOdxjG1ZDvVz0kkfWTZhcWXV+ajgOLL0LIVUOZspRyfJmHp+nPGA8IvpPhx/CgxRo6B47RoAqFdPCPJWCQn1a94vIv0SVKBRwFLB/SNSRLtaL83yOnaFAdBvQqmAUhcXO3iWjaJpTXQRLdoddVrxnFA86ysbo98EGbvhNw5+SsnUrW7gLbU9ICANiSJ1LG03es7wbhsOCsmcWSgtXtAC8ZbgNbeBFoHHMk3nZPjrNvHWeQOAGnhnkhrDgpp4QbStllRZnzPFKT1FB7gbNFoMxcRTchlzbzNruo2nC/CcxLdsIwWcNmqUE5BJef+1GswZ0KwuNVgoocUynqmklGn6sXiVag2H8YDmUfSvMp3n4GjOabTJUCOa9gbnjk2jS2+CRxDAm/rDx4rjsJTO6sl4avrrM54AM6K9nRWa1jeis686K3xorMCT6jA7ST1nyTbSlM3SOmZvR7KXqF3YvbqbkHQfoRMgolKGaoll86SUb8X3lZUqOhmGSbwdLmMbRBCXW5CmyqsW4V+YOsES9MbXgbB3DdU7hkpW5p3zC2a17K9A6p+wo3ym8bwAOxtmxzUHaKct+7VTnj2Bxr3BjJ7A5ULszFQYZ31tJ9vsONjGixyfsleTdQjd7b9PzJYNCyD9VzDc5ttAejiJnIMb/w8C4Y9C7Yt91Ut2DuT1iFxnhdP5hWZgkkQPEGTBnoUdD7seZrFoWobXOcgwSaLe9XDnsdPHM+Y8mYw5ZbEbPnU7usMK28GVmqicipYqb/ROF261IS9HSCsBac8RXb23QSO9yTVh7Flub3B61YD7QePkulxj/JCF+ykyP32NrR7xlS+wYvy4mpRW0GsxMBBQAJO01lG+JL6JFPzv/kw+4wT2UFGIzG70zVHRgh37Hb1aG8hHs5rEo9tGfhDt+PtHbSNuv4OnneW2WzxLQi6W/yDd/gvjB3evtDhDGo/7sJecLJ6Kcl9t9/uuDuQhXoDHXn7bb1oyrNvXr+YQPJA1/zRMMLd3jnPYdks8pDhuajOIVldCwbPTCC5ntfjad6rWjA4zad92ubVkXU7a1rkPd1To2pFtJ40UbBvXv64jMxDvUiO4E6bs0zT/rUOQHOH43K4ben5cyLiDSQi7sl8wdi/H5KQZO2Pws9piLeWhvBAN7zZ1dnLydIQp/3EUrGwWlBFvCGcRNe//HkxWvY6X21VA7eCxCKfG/4CRxGR+HJK34szHyuH+0jWr+xmAG26GbQe58YHqEUWmx9UlaG++VkafPcfzVhdk6I4FP01PrYVQEAfW1t7are7ambt2vl4mYokQrYDYUNQnF+/NxC+xNLu3XFHH9ScXEJyzsnNhZGziItHidPoWRDKRzYixch5GNm2haYz+NHIoUJm3qQCQslIBaEWWLMftL7SoDkjNDNYBSkhuGJpHwxEktBA9TAspdj3w7aCkx6Q4pAOgHWA+RD9zIiKDGp5s7bjA2VhZG49tf2qI8Z1sFlJFmEi9h3IWY6chRRCVf/iYkG5Jq/m5bf5t03k/+my+cJ5Vr+nm2+rr3fVYKv3XNIsQdJE/euhN8XkRUX+Ejl/rD+9fH6exvvszjNLU4eaL0qAPtMUUkUiFAnmyxadS5EnhOpREbTamCchUgAtAP+iSh2MF3CuBECRirnphVXIwxdo3KExsqc18lWPOHYdtwYeCnOPqnXotj5SyWKqqDTgGykyVGYilwE9w4uRXGEZUnUmzq3iNGkduxkBHqmAKcoDBEjKsWK7viexsXbYxLXywR+j4DvUNLPeYZ6bO41sj8P851sBtOhNxoUse7y/c+1b4M0JZsQisy6EttiwU2NPecAIhp6FSDIBNuhGt+6pQX27u6zU/x4CLDstuld4of592TMVROsgoiTnlDxStc43WSBZqhjcpZ46MFHNvrpqYFicpVXe2LJCu7LrtFSwRJUsu/OR+wAI5ixMAAjAJ9o8cxaXCaScsnGsZbf4A4tDmAFnG/jGP3JJtUaBiNNcAQurVZ4Eerrf79M0G2e78N1e3FGpaHHWPabXnhqBTSq2fdPet4nNm1ZQ1ElpHrqS35wTfjtSJ4Rskf5HQprDAW/qYdFZoix0TJQ7IMpFQ6Km1yLKukxUx7WaAAYn2BPeUP5RZEwbDPo2QikRnzIx15FzHLyGZXJeNNvc2ZafzqD35lqlk/U8U1K8Ngej3SCdERCaopVOsc35pxsEZ1FzBkBPqpcRF6EuIsZMZP6YgWjZOOAieD25J8466vKeuCCley0p7Us59l25s8ppH+A44oe3JjxgRvXTXF+zRCRlQmOcH0ED3xybImaElGf8PmKKrtNqKXsQdXDu/3RBTa/b27neMMNNTqhtX0vtyS+vj5pqyJRHyL+B8qgu5C+VR+imyiNrmHX/92p31pdzMrNuQE73jXL6NyWne4VMXNWkiwhDvcGXO6AXdH7N0085zektV6QhTajEwNlqrYSEwO/llK9ckbqT40LLGVakkxOns30lT/iXPHGDT0BDt92y0X7No4/vHRmtfn3z8x99oNm+0yn7Om/GnOU/ -------------------------------------------------------------------------------- /scripts/ExampleSubscriptionCallbacks.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | GET https://yourwebsite.com/path/to/callback/handler? \ 3 | hub.mode=subscribe& \ 4 | hub.topic=https://api.twitch.tv/helix/users/follows?first=1&to_id=1337& \ 5 | hub.lease_seconds=864000& \ 6 | hub.challenge=HzSGH_h04Cgl6VbDJm7IyXSNSlrhaLvBi9eft3bw 7 | 8 | GET https://yourwebsite.com/path/to/callback/handler? \ 9 | hub.mode=denied& \ 10 | hub.topic=https://api.twitch.tv/helix/users/follows?first=1&to_id=1337& \ 11 | hub.reason=unauthorized 12 | #> 13 | Enable-Tls -Tls12 -Confirm:$false 14 | $Params = @{ 15 | Uri = @( 16 | 'http://localhost:7071/api/TwitchWebhookIngestion/markekraus?' 17 | 'hub.mode=subscribe&' 18 | 'hub.topic=https://api.twitch.tv/helix/users/follows?first=1&to_id=1337&' 19 | 'hub.lease_seconds=864000&' 20 | 'hub.challenge=HzSGH_h04Cgl6VbDJm7IyXSNSlrhaLvBi9eft3bw' 21 | ) -join '' 22 | Method = 'Get' 23 | } 24 | $result = Invoke-WebRequest @Params 25 | $result 26 | 27 | $Params = @{ 28 | Uri = @( 29 | 'http://localhost:7071/api/TwitchWebhookIngestion/markekraus?' 30 | 'hub.mode=denied&' 31 | 'hub.topic=https://api.twitch.tv/helix/users/follows?first=1&to_id=1337&' 32 | 'hub.reason=unauthorized' 33 | ) -join '' 34 | Method = 'Get' 35 | } 36 | $FailResult = Invoke-WebRequest @Params 37 | $FailResult 38 | -------------------------------------------------------------------------------- /scripts/ExampleUsage.ps1: -------------------------------------------------------------------------------- 1 | Enable-Tls -Tls12 -Confirm:$false 2 | $secret = 'testing123' 3 | $hmacsha = New-Object System.Security.Cryptography.HMACSHA256 4 | $hmacsha.key = [Text.Encoding]::UTF8.GetBytes($secret) 5 | 6 | $Body = @{ 7 | data = @( 8 | @{ 9 | "id" = "0123456789" 10 | "user_id" = "403106760" 11 | "user_name" = "markekraus" 12 | "game_id" = "21779" 13 | "community_ids" = @() 14 | "type" = "live" 15 | "title" = "Best Stream Ever" 16 | "viewer_count" = 417 17 | "started_at" = "2017-12-01T10:09:45Z" 18 | "language" = "en" 19 | "thumbnail_url" = "https://link/to/thumbnail.jpg" 20 | } 21 | ) 22 | } | ConvertTo-Json 23 | 24 | 25 | $Hash = $hmacsha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Body)) 26 | $Hash = $(-join($Hash |ForEach-Object ToString X2)).ToLower() 27 | 28 | $StreamName = 'markekraus' 29 | 30 | $Params = @{ 31 | Uri = "http://localhost:7071/api/TwitchWebhookIngestion/{0}/{0}" -f $StreamName 32 | Method = 'Post' 33 | Body = $Body 34 | Headers = @{ 35 | 'X-Hub-Signature' = "sha256=$Hash" 36 | } 37 | ContentType = 'application/json' 38 | } 39 | Invoke-WebRequest @Params 40 | -------------------------------------------------------------------------------- /scripts/Invoke-TwitchSubscriptionRegistration.ps1: -------------------------------------------------------------------------------- 1 | function Register-TwitchSubscription { 2 | [CmdletBinding()] 3 | param ( 4 | [Parameter(Mandatory)] 5 | [ValidateNotNullOrEmpty()] 6 | [string] 7 | $Path, 8 | 9 | [Parameter(Mandatory)] 10 | [ValidateNotNullOrEmpty()] 11 | [string] 12 | $FunctionSecret, 13 | 14 | [Parameter(DontShow)] 15 | [string] 16 | $FunctionUri = 'https://twitchstreamnotifications2.azurewebsites.net/api/RegisterSubscription' 17 | ) 18 | 19 | end { 20 | try { 21 | $Body = Get-Content -Raw -Path $Path -ErrorAction stop 22 | } catch { 23 | $PSCmdlet.ThrowTerminatingError($_) 24 | } 25 | $Params = @{ 26 | Headers = @{ 27 | 'x-functions-key' = $FunctionSecret 28 | } 29 | Uri = $FunctionUri 30 | Method = 'POST' 31 | Body = $Body 32 | ContentType = 'application/json' 33 | } 34 | Invoke-RestMethod @Params 35 | } 36 | } 37 | function Clear-TwitchSubscription { 38 | [CmdletBinding()] 39 | param ( 40 | [Parameter(Mandatory)] 41 | [ValidateNotNullOrEmpty()] 42 | [string] 43 | $FunctionSecret, 44 | 45 | [Parameter(DontShow)] 46 | [string] 47 | $FunctionUri = 'https://twitchstreamnotifications2.azurewebsites.net/api/ClearSubscriptions' 48 | ) 49 | 50 | end { 51 | $Params = @{ 52 | Headers = @{ 53 | 'x-functions-key' = $FunctionSecret 54 | } 55 | Uri = $FunctionUri 56 | Method = 'POST' 57 | ContentType = 'application/json' 58 | } 59 | Invoke-RestMethod @Params 60 | } 61 | } 62 | function Get-TwitchSubscription { 63 | [CmdletBinding()] 64 | param ( 65 | [Parameter(Mandatory)] 66 | [ValidateNotNullOrEmpty()] 67 | [string] 68 | $FunctionSecret, 69 | 70 | [Parameter(DontShow)] 71 | [string] 72 | $FunctionUri = 'https://twitchstreamnotifications2.azurewebsites.net/api/GetSubscriptions' 73 | ) 74 | 75 | end { 76 | $Params = @{ 77 | Headers = @{ 78 | 'x-functions-key' = $FunctionSecret 79 | } 80 | Uri = $FunctionUri 81 | Method = 'GET' 82 | ContentType = 'application/json' 83 | } 84 | Invoke-RestMethod @Params 85 | } 86 | } 87 | Clear-TwitchSubscription -FunctionSecret $env:FunctionCode 88 | 89 | do { 90 | Start-Sleep -Seconds 10 91 | $Subscriptions = Get-TwitchSubscription -FunctionSecret $env:FunctionCode 92 | $Subscriptions.data.condition.count 93 | } while ($Subscriptions.data.condition.count -gt 0) 94 | 95 | $invokeTwitchSubscriptionRegistrationSplat = @{ 96 | Path = 'config/Subscriptions.json' 97 | FunctionSecret = $env:FunctionCode 98 | } 99 | Register-TwitchSubscription @invokeTwitchSubscriptionRegistrationSplat 100 | 101 | # ' ' 102 | # ' ' 103 | # ' ' 104 | # ' ' 105 | # ' ' 106 | # '-----------------' 107 | # 'Requested Subscriptions:' 108 | # $Result.RequestSubscriptions | Format-Table -AutoSize twitchname, twittername, discordname 109 | # ' ' 110 | # '-----------------' 111 | # 'Current Subscriptions:' 112 | # $Result.CurrentSubscriptions.subscription | Format-Table -AutoSize twitchname, twittername, discordname 113 | # ' ' 114 | # '-----------------' 115 | # 'Queued for Subscribe' 116 | # $Result.AddSubscriptions | Format-Table -AutoSize twitchname, twittername, discordname 117 | # ' ' 118 | # '-----------------' 119 | # 'Queued for Unsubscribe' 120 | # $Result.RemoveSubscriptions | Format-Table -AutoSize twitchname, twittername, discordname 121 | # ' ' 122 | # ' ' 123 | # '-----------------' 124 | # 'Queued for Renewal' 125 | # $Result.RenewSubscriptions | Format-Table -AutoSize twitchname, twittername, discordname 126 | # ' ' 127 | # ' ' 128 | -------------------------------------------------------------------------------- /scripts/RegisteringSubscriptions.ps1: -------------------------------------------------------------------------------- 1 | function New-TwitchApp { 2 | [CmdletBinding()] 3 | param ( 4 | [Parameter(Mandatory)] 5 | [ValidateNotNullOrEmpty()] 6 | [pscredential] 7 | $AppCredentials, 8 | 9 | [uri] 10 | $RedirectUri 11 | ) 12 | 13 | process { 14 | [PSCustomObject]@{ 15 | ClientId = $AppCredentials.UserName 16 | ClientSecret = $AppCredentials.GetNetworkCredential().Password 17 | RedirectUri = $RedirectUri 18 | } | Add-Member -MemberType ScriptMethod -Name ToString -Value {"TwitchApp"} -Force -PassThru 19 | } 20 | } 21 | 22 | function Get-TwitchOAuthToken { 23 | [CmdletBinding()] 24 | param ( 25 | [Parameter(Mandatory)] 26 | [ValidateNotNullOrEmpty()] 27 | $TwitchApp, 28 | 29 | [Parameter(DontShow)] 30 | [string] 31 | $BaseUri = 'https://id.twitch.tv/oauth2/token' 32 | ) 33 | 34 | process { 35 | $Params = @{ 36 | uri = $BaseUri 37 | Body = @{ 38 | 'client_id' = $TwitchApp.ClientId 39 | 'client_secret' = $TwitchApp.clientSecret 40 | 'grant_type' = 'client_credentials' 41 | } 42 | Method = 'Post' 43 | } 44 | $Response = Invoke-RestMethod @Params 45 | [PSCustomObject]@{ 46 | TwitchApp = $TwitchApp 47 | AccessToken = $Response.'access_token' 48 | RefreshToken = $Response.'refresh_token' 49 | } 50 | } 51 | } 52 | 53 | function Get-TwitchUser { 54 | [CmdletBinding()] 55 | param ( 56 | [Parameter(Mandatory, ValueFromPipeline)] 57 | [ValidateNotNullOrEmpty()] 58 | [string[]] 59 | $StreamName, 60 | 61 | [Parameter(Mandatory)] 62 | [ValidateNotNullOrEmpty()] 63 | $TwitchApp, 64 | 65 | [Parameter(DontShow)] 66 | [ValidateNotNullOrEmpty()] 67 | [string] 68 | $BaseUri = 'https://api.twitch.tv/helix/users' 69 | ) 70 | 71 | begin { 72 | $TwitchToken = Get-TwitchOAuthToken -TwitchApp $TwitchApp 73 | } 74 | process { 75 | foreach ($Stream in $StreamName) { 76 | $Params = @{ 77 | Method = "Get" 78 | Uri = '{0}?login={1}' -f $BaseUri, $Stream 79 | Headers = @{ 80 | 'Client-ID' = $TwitchApp.ClientId 81 | 'Authorization' = 'Bearer {0}' -f $TwitchToken.AccessToken 82 | } 83 | } 84 | (Invoke-RestMethod @Params).data 85 | } 86 | } 87 | } 88 | 89 | 90 | function Register-TwitchStreamWebhookSubscription { 91 | [CmdletBinding()] 92 | param ( 93 | [Parameter(Mandatory, ValueFromPipelineByPropertyName)] 94 | [ValidateNotNullOrEmpty()] 95 | [string] 96 | $StreamName, 97 | 98 | [Parameter(ValueFromPipelineByPropertyName)] 99 | [string] 100 | $TwitterName, 101 | 102 | [string] 103 | $HubSecret, 104 | 105 | [ValidateRange(0,864000)] 106 | [int] 107 | $HubLeaseSeconds = 0, 108 | 109 | [Parameter(Mandatory)] 110 | [ValidateNotNullOrEmpty()] 111 | $TwitchApp, 112 | 113 | [Parameter(DontShow)] 114 | [ValidateNotNullOrEmpty()] 115 | [string] 116 | $WebHookBaseUri = 'https://twitchstreamnotifications.azurewebsites.net/api/TwitchWebhookIngestion' 117 | ) 118 | 119 | process { 120 | $UserId = (Get-TwitchUser -StreamName $StreamName -TwitchApp $TwitchApp).id 121 | $CallBackUri = if ($TwitterName) { 122 | "{0}/{1}/{2}" -f $WebHookBaseUri, $StreamName, $TwitterName 123 | } else { 124 | "{0}/{1}" -f $WebHookBaseUri, $StreamName 125 | } 126 | $Body = @{ 127 | "hub.callback" = $CallBackUri 128 | "hub.topic" = "https://api.twitch.tv/helix/streams?user_id={0}" -f $UserId 129 | "hub.mode" = "subscribe" 130 | "hub.lease_seconds" = $HubLeaseSeconds 131 | } 132 | 133 | if ($HubSecret) { 134 | $Body['hub.secret'] = $HubSecret 135 | } 136 | 137 | $Params = @{ 138 | Uri = 'https://api.twitch.tv/helix/webhooks/hub' 139 | Method = 'Post' 140 | Body = $Body | ConvertTo-Json -Depth 10 141 | Headers = @{ 142 | 'Client-ID' = $TwitchApp.ClientId 143 | } 144 | ContentType = 'application/json' 145 | } 146 | $Null = Invoke-WebRequest @Params 147 | Start-Sleep -Milliseconds 100 148 | Get-TwitchStreamWebhookSubscription -TwitchApp $TwitchApp | where-object { 149 | $_.callback -eq $Body.'hub.callback' -and 150 | $_.topic -eq $Body.'hub.topic' 151 | } 152 | } 153 | } 154 | 155 | function Get-TwitchStreamWebhookSubscription { 156 | [CmdletBinding()] 157 | param ( 158 | [Parameter(Mandatory)] 159 | [ValidateNotNullOrEmpty()] 160 | $TwitchApp, 161 | 162 | [Parameter()] 163 | [ValidateRange(1,100)] 164 | [int] 165 | $First = 100, 166 | 167 | [Parameter(DontShow)] 168 | [ValidateNotNullOrEmpty()] 169 | $BaseUri = 'https://api.twitch.tv/helix/webhooks/subscriptions' 170 | ) 171 | 172 | process { 173 | $AccessToken = Get-TwitchOAuthToken -TwitchApp $TwitchApp 174 | $Params = @{ 175 | Uri = '{0}/?first={1}' -f $BaseUri, $First 176 | Method = 'GET' 177 | Headers = @{ 178 | 'Client-Id' = $TwitchApp.ClientId 179 | 'Authorization' = 'Bearer {0}' -f $AccessToken.AccessToken 180 | } 181 | ContentType = 'application/json' 182 | } 183 | $Result = (Invoke-RestMethod @Params).data 184 | $Result | ForEach-Object{ 185 | $Parts = $_.callback -split '/' 186 | switch ($Parts.Count) { 187 | 6 { 188 | $StreamName = $Parts[-1] 189 | $TwitterName = $null 190 | } 191 | 7 { 192 | $StreamName = $Parts[-2] 193 | $TwitterName = $Parts[-1] 194 | } 195 | Default {} 196 | } 197 | $_ | Add-Member -MemberType NoteProperty -Name StreamName -Value $StreamName 198 | $_ | Add-Member -MemberType NoteProperty -Name TwitterName -Value $TwitterName 199 | $_ | Add-Member -MemberType NoteProperty -Name TwitchApp -Value $TwitchApp -PassThru 200 | } 201 | } 202 | } 203 | 204 | function Unregister-TwitchStreamWebhookSubscription { 205 | [CmdletBinding()] 206 | param ( 207 | [Parameter(Mandatory, ValueFromPipelineByPropertyName)] 208 | [ValidateNotNullOrEmpty()] 209 | [string] 210 | $StreamName, 211 | 212 | [Parameter(ValueFromPipelineByPropertyName)] 213 | [string] 214 | $TwitterName, 215 | 216 | [string] 217 | $HubSecret, 218 | 219 | [Parameter(Mandatory, ValueFromPipelineByPropertyName)] 220 | [ValidateNotNullOrEmpty()] 221 | $TwitchApp, 222 | 223 | [Parameter(DontShow)] 224 | [ValidateNotNullOrEmpty()] 225 | [string] 226 | $WebHookBaseUri = 'https://twitchstreamnotifications.azurewebsites.net/api/TwitchWebhookIngestion' 227 | ) 228 | 229 | process { 230 | $UserId = (Get-TwitchUser -StreamName $StreamName -TwitchApp $TwitchApp).id 231 | $CallBackUri = if ($TwitterName) { 232 | "{0}/{1}/{2}" -f $WebHookBaseUri, $StreamName, $TwitterName 233 | } else { 234 | "{0}/{1}" -f $WebHookBaseUri, $StreamName 235 | } 236 | $Body = @{ 237 | "hub.callback" = $CallBackUri 238 | "hub.topic" = "https://api.twitch.tv/helix/streams?user_id={0}" -f $UserId 239 | "hub.mode" = "unsubscribe" 240 | "hub.lease_seconds" = 864000 241 | } 242 | 243 | if ($HubSecret) { 244 | $Body['hub.secret'] = $HubSecret 245 | } 246 | 247 | Write-verbose ("'{0}'" -f $Body."hub.callback") 248 | 249 | $JsonBody = $Body | ConvertTo-Json -Depth 10 250 | 251 | $Params = @{ 252 | Uri = 'https://api.twitch.tv/helix/webhooks/hub' 253 | Method = 'Post' 254 | Body = $JsonBody 255 | Headers = @{ 256 | 'Client-ID' = $TwitchApp.ClientId 257 | } 258 | ContentType = 'application/json' 259 | } 260 | Invoke-WebRequest @Params 261 | } 262 | } 263 | 264 | function Get-TwitchEvents { 265 | [CmdletBinding()] 266 | param ( 267 | [Parameter(Mandatory)] 268 | [ValidateNotNullOrEmpty()] 269 | $TwitchApp, 270 | 271 | [Parameter(Mandatory, ValueFromPipelineByPropertyName)] 272 | [ValidateNotNullOrEmpty()] 273 | [string] 274 | $StreamName, 275 | 276 | [Parameter(DontShow)] 277 | [ValidateNotNullOrEmpty()] 278 | $BaseUri = 'https://api.twitch.tv/v5' 279 | ) 280 | 281 | process { 282 | $UserId = (Get-TwitchUser -StreamName $StreamName -TwitchApp $TwitchApp).id 283 | $Params = @{ 284 | Uri = '{0}/channels/{1}/events' -f $BaseUri, $UserId 285 | Method = 'GET' 286 | Headers = @{ 287 | 'Client-ID' = $TwitchApp.ClientId 288 | } 289 | ContentType = 'application/json' 290 | } 291 | (Invoke-RestMethod @Params).events 292 | } 293 | } 294 | 295 | $AppCreds = Get-Credential 296 | $HubSecret = Read-Host -AsSecureString 297 | $HubSecret = ([pscredential]::new('foo', $HubSecret)).GetNetworkCredential().Password 298 | 299 | $TwitchApp = New-TwitchApp -AppCredentials $AppCreds -RedirectUri 'https://127.0.0.1/' 300 | Get-TwitchUser -StreamName markekraus -TwitchApp $TwitchApp -TwitchToken $TwitchToken 301 | $Result = Register-TwitchStreamWebhookSubscription -StreamName 'markekraus' -HubSecret $HubSecret -HubLeaseSeconds 0 -TwitchApp $TwitchApp 302 | 303 | $Subscriptions = Get-TwitchStreamWebhookSubscription -TwitchApp $TwitchApp -First 100 304 | $Subscriptions | Format-List StreamName, TwitterName, topic, callback 305 | $Subscriptions[0] | Unregister-TwitchStreamWebhookSubscription -HubSecret $HubSecret 306 | $Subscriptions | Unregister-TwitchStreamWebhookSubscription -HubSecret $HubSecret 307 | 308 | 309 | $Results = @( 310 | [PSCustomObject]@{StreamName='markekraus'; TwitterName='markekraus'} 311 | [PSCustomObject]@{StreamName='corbob'; TwitterName='CoryKnox'} 312 | [PSCustomObject]@{StreamName='halbaradkenafin'; TwitterName='halbaradkenafin'} 313 | [PSCustomObject]@{StreamName='MrThomasRayner'; TwitterName='MrThomasRayner'} 314 | [PSCustomObject]@{StreamName='steviecoaster'; TwitterName='steviecoaster'} 315 | [PSCustomObject]@{StreamName='PowerShellTeam'; TwitterName='PowerShell_Team'} 316 | [PSCustomObject]@{StreamName='tylerleonhardt'; TwitterName='TylerLeonhardt'} 317 | [PSCustomObject]@{StreamName='potatoqualitee'; TwitterName='cl'} 318 | [PSCustomObject]@{StreamName='kevinmarquette'; TwitterName='kevinmarquette'} 319 | [PSCustomObject]@{StreamName='PowerShellDoug'; TwitterName='dfinke'} 320 | [PSCustomObject]@{StreamName='glennsarti'; TwitterName='GlennSarti'} 321 | [PSCustomObject]@{StreamName='veronicageek'; TwitterName='veronicageek'} 322 | ) | Register-TwitchStreamWebhookSubscription -HubSecret $HubSecret -HubLeaseSeconds 864000 -TwitchApp $TwitchApp 323 | 324 | $FuncCode = Read-Host -AsSecureString 325 | $FuncCode = ([pscredential]::new('foo', $HubSecret)).GetNetworkCredential().Password 326 | 327 | function Invoke-TwitchSubscriptionRegistration { 328 | [CmdletBinding()] 329 | param ( 330 | [Parameter(Mandatory)] 331 | [ValidateNotNullOrEmpty()] 332 | [string] 333 | $Path, 334 | 335 | [Parameter(Mandatory)] 336 | [ValidateNotNullOrEmpty()] 337 | [string] 338 | $FunctionSecret, 339 | 340 | [Parameter(DontShow)] 341 | [string] 342 | $FunctionUri = 'https://twitchstreamnotifications.azurewebsites.net/api/TwitchSubscriptionRegistration' 343 | ) 344 | 345 | end { 346 | try { 347 | $Body = Get-Content -Raw -Path $Path -ErrorAction stop 348 | } catch { 349 | $PSCmdlet.ThrowTerminatingError($_) 350 | } 351 | $Params = @{ 352 | Headers = @{ 353 | 'x-functions-key' = $FunctionSecret 354 | } 355 | Uri = $FunctionUri 356 | Method = 'POST' 357 | Body = $Body 358 | ContentType = 'application/json' 359 | } 360 | Invoke-RestMethod @Params 361 | } 362 | } 363 | 364 | $Result = Invoke-TwitchSubscriptionRegistration -Path 'config/Subscriptions.json' -FunctionSecret $FuncCode 365 | '-----------------' 366 | 'Added:' 367 | $Result.AddSubscriptions 368 | ' ' 369 | 'Removed:' 370 | $Result.RemoveSubscriptions 371 | ' ' 372 | 'Requested:' 373 | $Result.RequestSubscriptions 374 | ' ' 375 | 'Current:' 376 | $Result.CurrentSubscriptions.subscription 377 | -------------------------------------------------------------------------------- /src/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | ..\obj\$(AssemblyName) 4 | ..\obj\$(AssemblyName) 5 | 6 | -------------------------------------------------------------------------------- /src/Models/DiscordMessage.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class DiscordMessage 7 | { 8 | 9 | [JsonProperty("content")] 10 | public string Content { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Models/Stream.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Newtonsoft.Json; 5 | using Markekraus.TwitchStreamNotifications.Models; 6 | using Markekraus.TwitchStreamNotifications; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace TwitchLib.Webhook.Models 10 | { 11 | public class Stream 12 | { 13 | 14 | private const string DefaultGame = "unknown game"; 15 | 16 | [JsonProperty("id")] 17 | public string Id { get; set; } 18 | 19 | [JsonProperty("user_id")] 20 | public string UserId { get; set; } 21 | 22 | [JsonProperty("user_name")] 23 | public string UserName { get; set; } 24 | 25 | [JsonProperty("game_id")] 26 | public string GameId { get; set; } 27 | 28 | [JsonProperty("community_ids")] 29 | public IList CommunityIds { get; set; } 30 | 31 | [JsonProperty("type")] 32 | public string Type { get; set; } 33 | 34 | [JsonProperty("title")] 35 | public string Title { get; set; } 36 | 37 | [JsonProperty("viewer_count")] 38 | public int ViewerCount { get; set; } 39 | 40 | [JsonProperty("started_at")] 41 | public DateTime StartedAt { get; set; } 42 | 43 | [JsonProperty("language")] 44 | public string Language { get; set; } 45 | 46 | [JsonProperty("thumbnail_url")] 47 | public string ThumbnailUrl { get; set; } 48 | 49 | [JsonProperty("subscription")] 50 | public TwitchSubscription Subscription { get; set; } 51 | 52 | public async Task GetGameName(ILogger Log) 53 | { 54 | string game; 55 | if(string.IsNullOrWhiteSpace(this.GameId)) 56 | { 57 | game = DefaultGame; 58 | } 59 | else 60 | { 61 | try 62 | { 63 | game = (await TwitchClient.GetGame(this.GameId, Log)).Name; 64 | } 65 | catch (Exception e) 66 | { 67 | Log.LogError($"Failed to get game. GameId {this.GameId}: {e.Message}: {e.StackTrace}"); 68 | game = DefaultGame; 69 | } 70 | if(string.IsNullOrWhiteSpace(game)) 71 | { 72 | Log.LogError($"GetGame returned null. GameId {this.GameId}"); 73 | game = DefaultGame; 74 | } 75 | } 76 | return game; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Models/StreamData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace TwitchLib.Webhook.Models 5 | { 6 | public class StreamData 7 | { 8 | 9 | [JsonProperty("data")] 10 | public IList Data { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Models/TwitchChannel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | 5 | namespace Markekraus.TwitchStreamNotifications.Models 6 | { 7 | public class TwitchChannel 8 | { 9 | [JsonProperty("mature")] 10 | public bool Mature { get; set; } 11 | 12 | [JsonProperty("status")] 13 | public string Status { get; set; } 14 | 15 | [JsonProperty("broadcaster_language")] 16 | public string BroadcasterLanguage { get; set; } 17 | 18 | [JsonProperty("broadcaster_software")] 19 | public string BroadcasterSoftware { get; set; } 20 | 21 | [JsonProperty("game")] 22 | public string Game { get; set; } 23 | 24 | [JsonProperty("language")] 25 | public string Language { get; set; } 26 | 27 | [JsonProperty("_id")] 28 | public int Id { get; set; } 29 | 30 | [JsonProperty("name")] 31 | public string Name { get; set; } 32 | 33 | [JsonProperty("created_at")] 34 | public DateTime CreatedAt { get; set; } 35 | 36 | [JsonProperty("updated_at")] 37 | public DateTime UpdatedAt { get; set; } 38 | 39 | [JsonProperty("partner")] 40 | public bool Partner { get; set; } 41 | 42 | [JsonProperty("logo")] 43 | public string Logo { get; set; } 44 | 45 | [JsonProperty("video_banner")] 46 | public string VideoBanner { get; set; } 47 | 48 | [JsonProperty("profile_banner")] 49 | public string ProfileBanner { get; set; } 50 | 51 | [JsonProperty("profile_banner_background_color")] 52 | public string ProfileBannerBackgroundColor { get; set; } 53 | 54 | [JsonProperty("url")] 55 | public Uri Url { get; set; } 56 | 57 | [JsonProperty("views")] 58 | public int Views { get; set; } 59 | 60 | [JsonProperty("followers")] 61 | public int Followers { get; set; } 62 | 63 | [JsonProperty("broadcaster_type")] 64 | public string BroadcasterType { get; set; } 65 | 66 | [JsonProperty("description")] 67 | public string description { get; set; } 68 | 69 | [JsonProperty("private_video")] 70 | public bool PrivateVideo { get; set; } 71 | 72 | [JsonProperty("privacy_options_enabled")] 73 | public bool PrivacyOptionsEnabled { get; set; } 74 | } 75 | } -------------------------------------------------------------------------------- /src/Models/TwitchChannelEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | 5 | namespace Markekraus.TwitchStreamNotifications.Models 6 | { 7 | public class TwitchChannelEvent 8 | { 9 | 10 | [JsonProperty("_id")] 11 | public string Id { get; set; } 12 | 13 | [JsonProperty("owner_id")] 14 | public int OwnerId { get; set; } 15 | 16 | [JsonProperty("channel_id")] 17 | public int ChannelId { get; set; } 18 | 19 | [JsonProperty("start_time")] 20 | public DateTime StartTime { get; set; } 21 | 22 | [JsonProperty("end_time")] 23 | public DateTime EndTime { get; set; } 24 | 25 | [JsonProperty("time_zone_id")] 26 | public string TimeZoneId { get; set; } 27 | 28 | [JsonProperty("title")] 29 | public string Title { get; set; } 30 | 31 | [JsonProperty("description")] 32 | public string Description { get; set; } 33 | 34 | [JsonProperty("game_id")] 35 | public string Int { get; set; } 36 | 37 | [JsonProperty("language")] 38 | public string Language { get; set; } 39 | 40 | [JsonProperty("cover_image_id")] 41 | public string CoverImageId { get; set; } 42 | 43 | [JsonProperty("cover_image_url")] 44 | public string CoverImageUrl { get; set; } 45 | 46 | [JsonProperty("channel")] 47 | public TwitchChannel Channel { get; set; } 48 | 49 | [JsonProperty("game")] 50 | public TwitchGame Game { get; set; } 51 | 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Models/TwitchChannelEventItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchChannelEventItem 7 | { 8 | [JsonProperty("event")] 9 | public TwitchChannelEvent Event { get; set; } 10 | 11 | [JsonProperty("subscription")] 12 | public TwitchSubscription Subscription { get; set; } 13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /src/Models/TwitchChannelEventResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchChannelEventResponse 7 | { 8 | 9 | [JsonProperty("_total")] 10 | public int Total { get; set; } 11 | 12 | [JsonProperty("events")] 13 | public IList Events { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Models/TwitchGame.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchGames 7 | { 8 | 9 | [JsonProperty("id")] 10 | public string Id { get; set; } 11 | 12 | [JsonProperty("name")] 13 | public string Name { get; set; } 14 | 15 | [JsonProperty("box_art_url")] 16 | public string BoxArtUrl { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Models/TwitchGamesData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchGamesData 7 | { 8 | 9 | [JsonProperty("data")] 10 | public IList Data { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Models/TwitchHubSubscription.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchHubSubscription 7 | { 8 | 9 | [JsonProperty("hub.callback")] 10 | public string HubCallback { get; set; } 11 | 12 | [JsonProperty("hub.topic")] 13 | public string HubTopic { get; set; } 14 | 15 | [JsonProperty("hub.mode")] 16 | public string HubMode { get; set; } 17 | 18 | [JsonProperty("hub.lease_seconds")] 19 | public int HubLeaseSeconds { get; set; } 20 | 21 | [JsonProperty("hub.secret")] 22 | public string HubSecret { get; set; } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Models/TwitchImage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchImage 7 | { 8 | [JsonProperty("large")] 9 | public string Large { get; set; } 10 | 11 | [JsonProperty("medium")] 12 | public string Medium { get; set; } 13 | 14 | [JsonProperty("small")] 15 | public string Small { get; set; } 16 | 17 | [JsonProperty("template")] 18 | public string Template { get; set; } 19 | } 20 | } -------------------------------------------------------------------------------- /src/Models/TwitchNotificationsEntry.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.WindowsAzure.Storage.Table; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchNotificationsEntry : TableEntity 7 | { 8 | public TwitchNotificationsEntry(string StreamName, string Id) 9 | { 10 | this.PartitionKey = StreamName; 11 | this.RowKey = Id; 12 | } 13 | 14 | public TwitchNotificationsEntry() { } 15 | 16 | public DateTime Date { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Models/TwitchOAuthResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | 5 | namespace Markekraus.TwitchStreamNotifications.Models 6 | { 7 | public class TwitchOAuthResponse 8 | { 9 | 10 | [JsonProperty("access_token")] 11 | public string AccessToken { get; set; } 12 | 13 | [JsonProperty("refresh_token")] 14 | public string RefreshToken { get; set; } 15 | 16 | [JsonProperty("expires_in")] 17 | public string ExpiresIn { get; set; } 18 | 19 | [JsonProperty("scope")] 20 | public IList Scope { get; set; } 21 | 22 | [JsonProperty("token_type")] 23 | public string TokenType { get; set; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Models/TwitchScheduledChannelEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchScheduledChannelEvent 7 | { 8 | [JsonProperty("type")] 9 | public TwitchScheduledChannelEventType Type { get; set; } 10 | 11 | [JsonProperty("event")] 12 | public TwitchChannelEventItem EventItem { get; set; } 13 | 14 | public TwitchScheduledChannelEvent() {} 15 | 16 | public TwitchScheduledChannelEvent(TwitchChannelEventItem EventItem) 17 | { 18 | this.EventItem = EventItem; 19 | this.Type = TwitchScheduledChannelEventType.Unknown; 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/Models/TwitchScheduledChannelEventType.cs: -------------------------------------------------------------------------------- 1 | namespace Markekraus.TwitchStreamNotifications.Models 2 | { 3 | public enum TwitchScheduledChannelEventType 4 | { 5 | Unknown, 6 | Hour, 7 | Day, 8 | Week 9 | } 10 | } -------------------------------------------------------------------------------- /src/Models/TwitchSubscription.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchSubscription 7 | { 8 | 9 | [JsonProperty("twitchname")] 10 | public string TwitchName { get; set; } 11 | 12 | [JsonProperty("twittername")] 13 | public string TwitterName { get; set; } 14 | 15 | [JsonProperty("discordname")] 16 | public string DiscordName { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Models/TwitchSubscriptionData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchSubscriptionData 7 | { 8 | 9 | [JsonProperty("data")] 10 | public IList Data { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Models/TwitchSubscriptionRegistrationResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | 5 | namespace Markekraus.TwitchStreamNotifications.Models 6 | { 7 | public class TwitchSubscriptionRegistrationResponse 8 | { 9 | [JsonProperty("RequestSubscriptions")] 10 | public IList RequestSubscriptions { get; set; } 11 | 12 | [JsonProperty("CurrentSubscriptions")] 13 | public IList CurrentSubscriptions { get; set; } 14 | 15 | [JsonProperty("AddSubscriptions")] 16 | public IList AddSubscriptions { get; set; } 17 | 18 | [JsonProperty("RemoveSubscriptions")] 19 | public IList RemoveSubscriptions { get; set; } 20 | 21 | [JsonProperty("RenewSubscriptions")] 22 | public IList RenewSubscriptions { get; set; } 23 | } 24 | } -------------------------------------------------------------------------------- /src/Models/TwitchUser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | //{ 5 | // "data": [{ 6 | // "id": "44322889", 7 | // "login": "dallas", 8 | // "display_name": "dallas", 9 | // "type": "staff", 10 | // "broadcaster_type": "", 11 | // "description": "Just a gamer playing games and chatting. :)", 12 | // "profile_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/dallas-profile_image-1a2c906ee2c35f12-300x300.png", 13 | // "offline_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/dallas-channel_offline_image-1a2c906ee2c35f12-1920x1080.png", 14 | // "view_count": 191836881, 15 | // "email": "login@provider.com" 16 | // }] 17 | //} 18 | namespace Markekraus.TwitchStreamNotifications.Models 19 | { 20 | public class TwitchUser 21 | { 22 | 23 | [JsonProperty("id")] 24 | public string Id { get; set; } 25 | 26 | [JsonProperty("login")] 27 | public string Login { get; set; } 28 | 29 | [JsonProperty("display_name")] 30 | public string DisplayName { get; set; } 31 | 32 | [JsonProperty("type")] 33 | public string Type { get; set; } 34 | 35 | [JsonProperty("broadcaster_type")] 36 | public string BroadcasterType { get; set; } 37 | 38 | [JsonProperty("description")] 39 | public string Description { get; set; } 40 | 41 | [JsonProperty("profile_image_url")] 42 | public string ProfileImageUrl { get; set; } 43 | 44 | [JsonProperty("offline_image_url")] 45 | public string OfflineImageUrl { get; set; } 46 | 47 | [JsonProperty("view_count")] 48 | public Int64 ViewCount { get; set; } 49 | 50 | [JsonProperty("email")] 51 | public string Email { get; set; } 52 | } 53 | } -------------------------------------------------------------------------------- /src/Models/TwitchUserData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchUserData 7 | { 8 | 9 | [JsonProperty("data")] 10 | public IList Data { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /src/Models/TwitchWebhookSubscription.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchWebhookSubscription 7 | { 8 | 9 | [JsonProperty("callback")] 10 | public string Callback { get; set; } 11 | 12 | [JsonProperty("topic")] 13 | public string Topic { get; set; } 14 | 15 | [JsonProperty("expires_at")] 16 | public string ExpiresAt { get; set; } 17 | 18 | private TwitchSubscription subscription; 19 | 20 | [JsonProperty("subscription")] 21 | public TwitchSubscription Subscription { 22 | get 23 | { 24 | if(subscription != null || string.IsNullOrWhiteSpace(Callback)) 25 | { 26 | return subscription; 27 | } 28 | else 29 | { 30 | subscription = new TwitchSubscription(); 31 | 32 | var parts = Callback.Split("/"); 33 | if(parts.Length >= 6) 34 | { 35 | subscription.TwitchName = parts[5]; 36 | } 37 | 38 | if (parts.Length >= 7 && parts[6] != Utility.NameNullString) 39 | { 40 | subscription.TwitterName = parts[6]; 41 | } 42 | 43 | if (parts.Length >= 8 && parts[7] != Utility.NameNullString) 44 | { 45 | subscription.DiscordName = parts[7]; 46 | } 47 | return subscription; 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Models/TwitchWebhookSubscriptionData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Markekraus.TwitchStreamNotifications.Models 5 | { 6 | public class TwitchWebhookSubscriptionData 7 | { 8 | 9 | [JsonProperty("data")] 10 | public IList Data { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /src/Models/TwtchGame.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | 5 | namespace Markekraus.TwitchStreamNotifications.Models 6 | { 7 | public class TwitchGame 8 | { 9 | [JsonProperty("name")] 10 | public string Name { get; set; } 11 | 12 | [JsonProperty("popularity")] 13 | public int Popularity { get; set; } 14 | 15 | [JsonProperty("_id")] 16 | public int Id { get; set; } 17 | 18 | [JsonProperty("giantbomb_id")] 19 | public string GiantbombID { get; set; } 20 | 21 | [JsonProperty("box")] 22 | public TwitchImage Box { get; set; } 23 | 24 | [JsonProperty("logo")] 25 | public TwitchImage Logo { get; set; } 26 | 27 | [JsonProperty("localized_name")] 28 | public string LocalizedName { get; set; } 29 | 30 | [JsonProperty("locale")] 31 | public string Locale { get; set; } 32 | } 33 | } -------------------------------------------------------------------------------- /src/TwitchStreamNotifcations.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp3.1 4 | v3 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | PreserveNewest 14 | 15 | 16 | PreserveNewest 17 | Never 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/functions/DiscordEventHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Markekraus.TwitchStreamNotifications.Models; 4 | using Microsoft.Azure.WebJobs; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Markekraus.TwitchStreamNotifications 8 | { 9 | public static class DiscordEventHandler 10 | { 11 | private readonly static string DiscordMessageTemplate = Environment.GetEnvironmentVariable("DiscordMessageTemplate"); 12 | 13 | [FunctionName("DiscordEventHandler")] 14 | public static async Task Run( 15 | [QueueTrigger("%DiscordNotifications%", Connection = "TwitchStreamStorage")] 16 | TwitchLib.Webhook.Models.Stream StreamEvent, 17 | ILogger log) 18 | { 19 | log.LogInformation($"DiscordEventHandler processing: {StreamEvent.UserName} type {StreamEvent.Type} started at {StreamEvent.StartedAt}"); 20 | 21 | if (StreamEvent.Type != "live") 22 | { 23 | log.LogInformation($"DiscordEventHandler Processing event skipped. type: {StreamEvent.Type}"); 24 | return; 25 | } 26 | 27 | string username; 28 | if (string.IsNullOrWhiteSpace(StreamEvent.Subscription.DiscordName)) 29 | { 30 | username = StreamEvent.UserName; 31 | log.LogInformation($"DiscordEventHandler Stream username {username} will be used"); 32 | } 33 | else 34 | { 35 | username = $"<@{StreamEvent.Subscription.DiscordName}>"; 36 | log.LogInformation($"DiscordEventHandler Discord username {username} will be used"); 37 | } 38 | 39 | var game = await StreamEvent.GetGameName(log); 40 | log.LogInformation($"DiscordEventHandler game {game} will be used"); 41 | 42 | string streamUri = $"https://twitch.tv/{StreamEvent.UserName}"; 43 | log.LogInformation($"DiscordEventHandler Stream Uri: {streamUri}"); 44 | 45 | var myDiscordMessage = new DiscordMessage() 46 | { 47 | Content = string.Format(DiscordMessageTemplate, streamUri, username, DateTime.UtcNow.ToString("u"), game) 48 | }; 49 | 50 | await DiscordClient.SendDiscordMessageAsync(myDiscordMessage, log); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/functions/DiscordScheduledEventNotifier.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Markekraus.TwitchStreamNotifications.Models; 4 | using Microsoft.Azure.WebJobs; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Markekraus.TwitchStreamNotifications 8 | { 9 | public static class DiscordScheduledEventNotifier 10 | { 11 | private readonly static string DiscordMessageTemplate = Environment.GetEnvironmentVariable("DiscordScheduledEventMessageTemplate"); 12 | 13 | [FunctionName("DiscordScheduledEventNotifier")] 14 | public static async Task Run( 15 | [QueueTrigger("%DiscordEventNotificationsQueue%", Connection = "TwitchStreamStorage")] TwitchScheduledChannelEvent ScheduledEvent, 16 | ILogger log) 17 | { 18 | log.LogInformation($"DiscordScheduledEventNotifier function processed: TwitchName {ScheduledEvent.EventItem.Subscription.TwitchName} DiscordName {ScheduledEvent.EventItem.Subscription.DiscordName} EventID {ScheduledEvent.EventItem.Event.Id} NotificationType {ScheduledEvent.Type}"); 19 | 20 | var subscription = ScheduledEvent.EventItem.Subscription; 21 | var channelEvent = ScheduledEvent.EventItem.Event; 22 | var eventType = ScheduledEvent.Type; 23 | 24 | if (eventType == TwitchScheduledChannelEventType.Unknown) 25 | { 26 | log.LogInformation($"DiscordScheduledEventNotifier Processing event skipped. TwitchName {ScheduledEvent.EventItem.Subscription.TwitchName} DiscordName {ScheduledEvent.EventItem.Subscription.DiscordName} EventID {ScheduledEvent.EventItem.Event.Id} NotificationType {ScheduledEvent.Type}"); 27 | return; 28 | } 29 | 30 | string username; 31 | if (string.IsNullOrWhiteSpace(subscription.DiscordName) || subscription.DiscordName == Utility.NameNullString) 32 | { 33 | username = subscription.TwitchName; 34 | log.LogInformation($"DiscordScheduledEventNotifier Stream username {username} will be used"); 35 | } 36 | else 37 | { 38 | username = $"<@{subscription.DiscordName}>"; 39 | log.LogInformation($"DiscordScheduledEventNotifier Discord username {username} will be used"); 40 | } 41 | 42 | string eventUri = $"https://www.twitch.tv/events/{channelEvent.Id}"; 43 | log.LogInformation($"DiscordScheduledEventNotifier Event Uri: {eventUri}"); 44 | 45 | var myDiscordMessage = new DiscordMessage() 46 | { 47 | Content = string.Format( 48 | DiscordMessageTemplate, 49 | eventUri, 50 | username, 51 | Utility.TypeStringLookup[eventType], 52 | channelEvent.StartTime.ToString("u"), 53 | channelEvent.Title) 54 | }; 55 | 56 | await DiscordClient.SendDiscordMessageAsync(myDiscordMessage, log); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/functions/TwitchChannelEventLookup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Markekraus.TwitchStreamNotifications.Models; 4 | using Microsoft.Azure.WebJobs; 5 | using Microsoft.Azure.WebJobs.Host; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Markekraus.TwitchStreamNotifications 9 | { 10 | public static class TwitchChannelEventLookup 11 | { 12 | [FunctionName("TwitchChannelEventLookup")] 13 | public static async Task Run( 14 | [QueueTrigger("%TwitchChannelEventLookupQueue%", Connection = "TwitchStreamStorage")] TwitchSubscription Subscription, 15 | [Queue("%TwitchChannelEventProcessQueue%", Connection = "TwitchStreamStorage")] IAsyncCollector EventProccessQueue, 16 | ILogger log) 17 | { 18 | log.LogInformation($"TwitchChannelEventLookup function processed: {Subscription.TwitchName}"); 19 | 20 | var response = await TwitchClient.GetTwitchSubscriptionEvents(Subscription, log); 21 | 22 | foreach (var channelEvent in response.Events) 23 | { 24 | log.LogInformation($"TwitchChannelEventLookup Queing event {channelEvent.Id} for channel {Subscription.TwitchName}"); 25 | await EventProccessQueue.AddAsync(new TwitchChannelEventItem() 26 | { 27 | Event = channelEvent, 28 | Subscription = Subscription 29 | }); 30 | } 31 | 32 | log.LogInformation("TwitchChannelEventLookup end"); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/functions/TwitchChannelEventProcess.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Markekraus.TwitchStreamNotifications.Models; 4 | using Microsoft.Azure.WebJobs; 5 | using Microsoft.Azure.WebJobs.Host; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Markekraus.TwitchStreamNotifications 9 | { 10 | public static class TwitchChannelEventProcess 11 | { 12 | [FunctionName("TwitchChannelEventProcess")] 13 | public static async Task Run( 14 | [QueueTrigger("%TwitchChannelEventProcessQueue%", Connection = "TwitchStreamStorage")] TwitchChannelEventItem ChannelEventItem, 15 | [Queue("%DiscordEventNotificationsQueue%")] IAsyncCollector DiscordQueue, 16 | [Queue("%TwitterEventNotificationsQueue%")] IAsyncCollector TwitterQueue, 17 | ILogger log) 18 | { 19 | log.LogInformation($"TwitchChannelEventProcess function processed: TwitchName {ChannelEventItem.Subscription.TwitchName} EventId {ChannelEventItem.Event.Id} StartTime {ChannelEventItem.Event.StartTime}"); 20 | 21 | var channelEvent = ChannelEventItem.Event; 22 | 23 | var now = DateTime.UtcNow; 24 | var nowHour = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, DateTimeKind.Utc); 25 | var hourStart = nowHour.AddHours(1); 26 | var hourEnd = hourStart.AddHours(1).AddSeconds(-1); 27 | var weekStart = nowHour.AddDays(7); 28 | var weekEnd = weekStart.AddHours(1).AddSeconds(-1); 29 | var dayStart = nowHour.AddDays(1); 30 | var dayEnd = dayStart.AddHours(1).AddSeconds(-1); 31 | 32 | log.LogInformation($"TwitchChannelEventProcess Now {now}"); 33 | log.LogInformation($"TwitchChannelEventProcess HourStart {hourStart}"); 34 | log.LogInformation($"TwitchChannelEventProcess HourEnd {hourEnd}"); 35 | log.LogInformation($"TwitchChannelEventProcess DayStart {dayStart}"); 36 | log.LogInformation($"TwitchChannelEventProcess DayEnd {dayEnd}"); 37 | log.LogInformation($"TwitchChannelEventProcess WeekStart {weekStart}"); 38 | log.LogInformation($"TwitchChannelEventProcess WeekEnd {weekEnd}"); 39 | 40 | var scheduledEvent = new TwitchScheduledChannelEvent(ChannelEventItem); 41 | 42 | if (channelEvent.StartTime >= hourStart && channelEvent.StartTime <= hourEnd) 43 | { 44 | log.LogInformation($"TwitchChannelEventProcess TwitchName {ChannelEventItem.Subscription.TwitchName} EventId {ChannelEventItem.Event.Id} in an hour {channelEvent.StartTime}"); 45 | scheduledEvent.Type = TwitchScheduledChannelEventType.Hour; 46 | } 47 | else if (channelEvent.StartTime >= dayStart && channelEvent.StartTime <= dayEnd) 48 | { 49 | log.LogInformation($"TwitchChannelEventProcess TwitchName {ChannelEventItem.Subscription.TwitchName} EventId {ChannelEventItem.Event.Id} in a day {channelEvent.StartTime}"); 50 | scheduledEvent.Type = TwitchScheduledChannelEventType.Day; 51 | } 52 | else if (channelEvent.StartTime >= weekStart && channelEvent.StartTime <= weekEnd) 53 | { 54 | log.LogInformation($"TwitchChannelEventProcess TwitchName {ChannelEventItem.Subscription.TwitchName} EventId {ChannelEventItem.Event.Id} in a week {channelEvent.StartTime}"); 55 | scheduledEvent.Type = TwitchScheduledChannelEventType.Week; 56 | } 57 | else 58 | { 59 | log.LogInformation($"TwitchChannelEventProcess TwitchName {ChannelEventItem.Subscription.TwitchName} EventId {ChannelEventItem.Event.Id} Unknown {channelEvent.StartTime}"); 60 | scheduledEvent.Type = TwitchScheduledChannelEventType.Unknown; 61 | } 62 | 63 | if (scheduledEvent.Type != TwitchScheduledChannelEventType.Unknown) 64 | { 65 | log.LogInformation($"TwitchChannelEventProcess Queing TwitchName {scheduledEvent.EventItem.Subscription.TwitchName} EventId {scheduledEvent.EventItem.Event.Id} Type {scheduledEvent.Type}"); 66 | await DiscordQueue.AddAsync(scheduledEvent); 67 | await TwitterQueue.AddAsync(scheduledEvent); 68 | } 69 | 70 | log.LogInformation($"TwitchChannelEventProcess End"); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/functions/TwitchScheduledGetSubscriptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Markekraus.TwitchStreamNotifications.Models; 4 | using Microsoft.Azure.WebJobs; 5 | using Microsoft.Azure.WebJobs.Host; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Markekraus.TwitchStreamNotifications 9 | { 10 | public static class TwitchScheduledGetSubscriptions 11 | { 12 | [FunctionName("TwitchScheduledGetSubscriptions")] 13 | public static async Task Run( 14 | [TimerTrigger("0 0 * * * *")] TimerInfo myTimer, 15 | [Queue("%TwitchChannelEventLookupQueue%", Connection = "TwitchStreamStorage")] IAsyncCollector EventLookupQueue, 16 | ILogger log) 17 | { 18 | log.LogInformation($"TwitchScheduledGetSubscriptions function executed at: {DateTime.Now}"); 19 | log.LogInformation("TwitchScheduledGetSubscriptions Get current subscriptions"); 20 | var currentSubscriptions = await TwitchClient.GetTwitchWebhookSubscriptions(log); 21 | foreach (var currentSubscription in currentSubscriptions) 22 | { 23 | log.LogInformation($"TwitchScheduledGetSubscriptions Queuing TwitchName {currentSubscription.Subscription.TwitchName} TwitterName {currentSubscription.Subscription.TwitterName} DiscordName {currentSubscription.Subscription.DiscordName}"); 24 | await EventLookupQueue.AddAsync(currentSubscription.Subscription); 25 | } 26 | 27 | log.LogInformation("TwitchScheduledGetSubscriptions end"); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/functions/TwitchScheduledSubscriptionRegistration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Markekraus.TwitchStreamNotifications.Models; 5 | using Microsoft.Azure.WebJobs; 6 | using Microsoft.Azure.WebJobs.Host; 7 | using Microsoft.Extensions.Logging; 8 | using Newtonsoft.Json; 9 | 10 | namespace Markekraus.TwitchStreamNotifications 11 | { 12 | public static class TwitchScheduledSubscriptionRegistration 13 | { 14 | [FunctionName("TwitchScheduledSubscriptionRegistration")] 15 | public static async Task Run( 16 | [TimerTrigger("0 0 * * * *")] TimerInfo myTimer, 17 | [Blob("%TwitchSubscriptionBlob%", Connection = "TwitchStreamStorage")] string SubscriptionJsonContent, 18 | [Queue("%TwitchSubscribeQueue%", Connection = "TwitchStreamStorage")] IAsyncCollector SubscribeQueue, 19 | [Queue("%TwitchUnsubscribeQueue%", Connection = "TwitchStreamStorage")] IAsyncCollector UnsubscribeQueue, 20 | ILogger log) 21 | { 22 | log.LogInformation($"{nameof(TwitchScheduledSubscriptionRegistration)} function executed at: {DateTime.Now}"); 23 | log.LogInformation($"{nameof(TwitchScheduledSubscriptionRegistration)} SubscriptionJsonContent: {SubscriptionJsonContent}"); 24 | var TwitchSubscriptions = JsonConvert.DeserializeObject>(SubscriptionJsonContent); 25 | log.LogInformation($"Count: {TwitchSubscriptions.Count}"); 26 | var result = await TwitchClient.InvokeSubscriptionRegistration(TwitchSubscriptions, SubscribeQueue, UnsubscribeQueue, log, nameof(TwitchScheduledSubscriptionRegistration)); 27 | var resultString = JsonConvert.SerializeObject(result); 28 | log.LogInformation($"{nameof(TwitchScheduledSubscriptionRegistration)} Result: {resultString}"); 29 | log.LogInformation($"{nameof(TwitchScheduledSubscriptionRegistration)} End"); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/functions/TwitchStreamEventHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Azure.WebJobs; 3 | using Microsoft.Azure.WebJobs.Host; 4 | using Microsoft.Extensions.Logging; 5 | using TwitchLib.Webhook.Models; 6 | using Microsoft.WindowsAzure.Storage.Table; 7 | using Markekraus.TwitchStreamNotifications.Models; 8 | using System.Threading.Tasks; 9 | using Microsoft.WindowsAzure.Storage; 10 | 11 | namespace Markekraus.TwitchStreamNotifications 12 | { 13 | public static class TwitchStreamEventHandler 14 | { 15 | [FunctionName("TwitchStreamEventHandler")] 16 | public static async Task Run( 17 | [QueueTrigger("%TwitchStreamActivity%", Connection = "TwitchStreamStorage")] Stream StreamEvent, 18 | [Queue("%TwitterNotifications%")] IAsyncCollector TwitterQueue, 19 | [Queue("%DiscordNotifications%")] IAsyncCollector DiscordQueue, 20 | [Table("%TwitchNotificationsTable%")] CloudTable NotificationsTable, 21 | ILogger log) 22 | { 23 | log.LogInformation($"TwitchStreamEventHandler processing: {StreamEvent.UserName} type {StreamEvent.Type} started at {StreamEvent.StartedAt}"); 24 | 25 | var retrieveOperation = TableOperation.Retrieve(StreamEvent.UserName.ToLower(), StreamEvent.Id.ToLower()); 26 | try 27 | { 28 | var retrievedResult = await NotificationsTable.ExecuteAsync(retrieveOperation); 29 | if (retrievedResult.Result != null) 30 | { 31 | log.LogWarning($"Notifications for StreamName {StreamEvent.UserName} Id {StreamEvent.Id} have already been queued"); 32 | return; 33 | } 34 | } 35 | catch (StorageException e) 36 | { 37 | if (e.RequestInformation != null && e.RequestInformation.HttpStatusCode == 404) 38 | { 39 | log.LogInformation($"Notifications for StreamName {StreamEvent.UserName} Id {StreamEvent.Id} have already not been queued"); 40 | } 41 | else 42 | { 43 | log.LogError(e, "Unkown Exception"); 44 | return; 45 | } 46 | } 47 | catch (Exception e) 48 | { 49 | log.LogError(e, "Unkown Exception"); 50 | return; 51 | } 52 | 53 | var tableEntry = new TwitchNotificationsEntry(StreamEvent.UserName.ToLower(), StreamEvent.Id.ToLower()); 54 | tableEntry.Date = DateTime.UtcNow; 55 | var insertOperation = TableOperation.Insert(tableEntry); 56 | try 57 | { 58 | await NotificationsTable.ExecuteAsync(insertOperation); 59 | log.LogInformation($"Add StreamName {StreamEvent.UserName} Id {StreamEvent.Id} to Table {NotificationsTable.Name}"); 60 | } 61 | catch (Exception e) 62 | { 63 | log.LogWarning($"Notifications for StreamName {StreamEvent.UserName} Id {StreamEvent.Id} have already been queued???"); 64 | log.LogError(e, "Exception"); 65 | return; 66 | } 67 | 68 | log.LogInformation($"{nameof(TwitterQueue)} add {StreamEvent.UserName} type {StreamEvent.Type} started at {StreamEvent.StartedAt}"); 69 | await TwitterQueue.AddAsync(StreamEvent); 70 | 71 | log.LogInformation($"{nameof(DiscordQueue)} add {StreamEvent.UserName} type {StreamEvent.Type} started at {StreamEvent.StartedAt}"); 72 | await DiscordQueue.AddAsync(StreamEvent); 73 | 74 | log.LogInformation("TwitchStreamEventHandler complete"); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/functions/TwitchSubscriptionAdd.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.WebJobs; 2 | using Microsoft.Extensions.Logging; 3 | using Markekraus.TwitchStreamNotifications.Models; 4 | using System.Threading.Tasks; 5 | 6 | namespace Markekraus.TwitchStreamNotifications 7 | { 8 | public static class TwitchSubscriptionAdd 9 | { 10 | [FunctionName("TwitchSubscriptionAdd")] 11 | public static async Task Run( 12 | [QueueTrigger("%TwitchSubscribeQueue%", Connection = "TwitchStreamStorage")] 13 | TwitchSubscription Subscription, 14 | ILogger log) 15 | { 16 | log.LogInformation("TwitchSubscriptionAdd Begin"); 17 | 18 | log.LogInformation($"TwitchSubscriptionAdd Process TwitchName {Subscription.TwitchName} TwitterName {Subscription.TwitterName}"); 19 | 20 | log.LogInformation($"TwitchSubscriptionAdd Subscribing TwitchName {Subscription.TwitchName} TwitterName {Subscription.TwitterName}"); 21 | try 22 | { 23 | await TwitchClient.SubscribeTwitchStreamWebhook(Subscription, log); 24 | log.LogInformation($"TwitchSubscriptionAdd Subscribed TwitchName {Subscription.TwitchName} TwitterName {Subscription.TwitterName}"); 25 | } 26 | catch (System.Exception e) 27 | { 28 | log.LogError(e, "TwitchSubscriptionAdd exception subscribing"); 29 | } 30 | 31 | log.LogInformation("TwitchSubscriptionAdd End"); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/functions/TwitchSubscriptionRegistration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Azure.WebJobs; 6 | using Microsoft.Azure.WebJobs.Extensions.Http; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.Extensions.Logging; 9 | using Newtonsoft.Json; 10 | using Markekraus.TwitchStreamNotifications.Models; 11 | using System.Collections.Generic; 12 | using System.Linq; 13 | 14 | namespace Markekraus.TwitchStreamNotifications 15 | { 16 | [StorageAccount("TwitchStreamStorage")] 17 | public static class TwitchSubscriptionRegistration 18 | { 19 | [FunctionName("TwitchSubscriptionRegistration")] 20 | public static async Task Run( 21 | [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] 22 | HttpRequest req, 23 | [Queue("%TwitchSubscribeQueue%", Connection = "TwitchStreamStorage")] IAsyncCollector SubscribeQueue, 24 | [Queue("%TwitchUnsubscribeQueue%", Connection = "TwitchStreamStorage")] IAsyncCollector UnsubscribeQueue, 25 | ILogger log) 26 | { 27 | log.LogInformation($"{nameof(TwitchSubscriptionRegistration)} processed a request."); 28 | 29 | var requestBody = await req.ReadAsStringAsync(); 30 | log.LogInformation($"{nameof(TwitchSubscriptionRegistration)} RequestBody: {requestBody}"); 31 | var TwitchSubscriptions = JsonConvert.DeserializeObject>(requestBody); 32 | log.LogInformation($"{nameof(TwitchSubscriptionRegistration)} Count: {TwitchSubscriptions.Count}"); 33 | 34 | var result = await TwitchClient.InvokeSubscriptionRegistration(TwitchSubscriptions, SubscribeQueue, UnsubscribeQueue, log, nameof(TwitchSubscriptionRegistration)); 35 | 36 | log.LogInformation($"{nameof(TwitchSubscriptionRegistration)} End"); 37 | var responseString = JsonConvert.SerializeObject(result); 38 | return new OkObjectResult(responseString); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/functions/TwitchSubscriptionRemove.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.WebJobs; 2 | using Microsoft.Extensions.Logging; 3 | using Markekraus.TwitchStreamNotifications.Models; 4 | using System.Threading.Tasks; 5 | 6 | namespace Markekraus.TwitchStreamNotifications 7 | { 8 | public static class TwitchSubscriptionRemove 9 | { 10 | [FunctionName("TwitchSubscriptionRemove")] 11 | public static async Task Run( 12 | [QueueTrigger("%TwitchUnsubscribeQueue%", Connection = "TwitchStreamStorage")] 13 | TwitchSubscription Subscription, 14 | ILogger log) 15 | { 16 | log.LogInformation("TwitchSubscriptionRemove Begin"); 17 | 18 | log.LogInformation($"TwitchSubscriptionRemove Process TwitchName {Subscription.TwitchName} TwitterName {Subscription.TwitterName}"); 19 | 20 | log.LogInformation($"TwitchSubscriptionRemove Unsubscribing TwitchName {Subscription.TwitchName} TwitterName {Subscription.TwitterName}"); 21 | try 22 | { 23 | await TwitchClient.UnsubscribeTwitchStreamWebhook(Subscription, log); 24 | log.LogInformation($"TwitchSubscriptionRemove Unsubscribed TwitchName {Subscription.TwitchName} TwitterName {Subscription.TwitterName}"); 25 | } 26 | catch (System.Exception e) 27 | { 28 | log.LogError(e, "TwitchSubscriptionRemove exception unsubscribing"); 29 | } 30 | 31 | log.LogInformation("TwitchSubscriptionRemove End"); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/functions/TwitchWebhookIngestion.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.Azure.WebJobs; 5 | using Microsoft.Azure.WebJobs.Extensions.Http; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.Extensions.Logging; 8 | using TwitchLib.Webhook.Models; 9 | using Newtonsoft.Json; 10 | using Markekraus.TwitchStreamNotifications.Models; 11 | using Stream = TwitchLib.Webhook.Models.Stream; 12 | 13 | namespace Markekraus.TwitchStreamNotifications 14 | { 15 | 16 | [StorageAccount("TwitchStreamStorage")] 17 | public static class TwitchWebhookIngestion 18 | { 19 | private const string SignatureHeader = "X-Hub-Signature"; 20 | private static readonly string HashSecret = Environment.GetEnvironmentVariable("TwitchSubscriptionsHashSecret"); 21 | 22 | [FunctionName("TwitchWebhookIngestion")] 23 | public static async Task Run( 24 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", "get", Route = "TwitchWebhookIngestion/{StreamName}/{TwitterName?}/{DiscordName?}")] HttpRequest Req, 25 | string StreamName, 26 | string TwitterName, 27 | string DiscordName, 28 | [Queue("%TwitchStreamActivity%")] ICollector queue, 29 | ILogger Log) 30 | { 31 | Log.LogInformation($"TwitchWebhookIngestion function processed a request."); 32 | Log.LogInformation($"StreamName: {StreamName}"); 33 | Log.LogInformation($"TwitterName: {TwitterName}"); 34 | Log.LogInformation($"DiscordName: {DiscordName}"); 35 | 36 | var subscription = new TwitchSubscription() 37 | { 38 | TwitchName = StreamName, 39 | TwitterName = TwitterName != Utility.NameNullString ? TwitterName : string.Empty, 40 | DiscordName = DiscordName != Utility.NameNullString ? DiscordName : string.Empty 41 | }; 42 | 43 | if (Req.Query.TryGetValue("hub.mode", out var hubMode)) 44 | { 45 | Log.LogInformation($"Received hub.mode Query string: {Req.QueryString}"); 46 | if (hubMode.ToString().ToLower() == "subscribe" || hubMode.ToString().ToLower() == "unsubscribe") 47 | { 48 | Log.LogInformation($"Returning hub.challenge {Req.Query["hub.challenge"]}"); 49 | return new OkObjectResult(Req.Query["hub.challenge"].ToString()); 50 | } 51 | else 52 | { 53 | Log.LogError($"Failed subscription: {Req.QueryString}"); 54 | // Subscription hub expects 200 result when subscription fails 55 | return new OkResult(); 56 | } 57 | } 58 | else 59 | { 60 | Log.LogInformation("No hub.mode supplied."); 61 | } 62 | 63 | Log.LogInformation("Processing body stream"); 64 | Log.LogInformation($"CanSeek: {Req.Body.CanSeek}"); 65 | 66 | var bodyString = await Req.ReadAsStringAsync(); 67 | Log.LogInformation("Payload:"); 68 | Log.LogInformation(bodyString); 69 | 70 | StreamData webhook; 71 | try 72 | { 73 | webhook = JsonConvert.DeserializeObject(bodyString); 74 | } 75 | catch (Exception e) 76 | { 77 | Log.LogError($"Invalid JSON. exception {e.Message}. {bodyString}"); 78 | return new BadRequestResult(); 79 | } 80 | 81 | Log.LogInformation($"Request contains {webhook.Data.Count} objects."); 82 | 83 | if (!Req.Headers.TryGetValue(SignatureHeader, out var signature)) 84 | { 85 | Log.LogError($"Missing {SignatureHeader} header"); 86 | return new BadRequestResult(); 87 | } 88 | 89 | var fields = signature.ToString().Split("="); 90 | if (fields.Length != 2) 91 | { 92 | Log.LogError($"Malformed {SignatureHeader} header. Missing '='?"); 93 | return new BadRequestObjectResult(signature); 94 | } 95 | 96 | var header = fields[1]; 97 | if (string.IsNullOrEmpty(header)) 98 | { 99 | Log.LogError($"Malformed {SignatureHeader} header. Signature is null or empty"); 100 | return new BadRequestObjectResult(fields); 101 | } 102 | 103 | var expectedHash = Utility.FromHex(header); 104 | if (expectedHash == null) 105 | { 106 | Log.LogError($"Malformed {SignatureHeader} header. Invalid hex signature"); 107 | return new BadRequestObjectResult(SignatureHeader); 108 | } 109 | 110 | var actualHash = await Utility.ComputeRequestBodySha256HashAsync(Req, HashSecret); 111 | 112 | if (!Utility.SecretEqual(expectedHash, actualHash)) 113 | { 114 | Log.LogError($"Signature mismatch. actaulHash {Convert.ToBase64String(actualHash)} did not match expectedHash {Convert.ToBase64String(expectedHash)}"); 115 | return new BadRequestObjectResult(signature); 116 | } 117 | 118 | foreach (var item in webhook.Data) 119 | { 120 | if (string.IsNullOrWhiteSpace(item.UserName)) 121 | { 122 | Log.LogInformation($"Setting missing Username to {StreamName}"); 123 | item.UserName = StreamName; 124 | } 125 | 126 | item.Subscription = subscription; 127 | 128 | Log.LogInformation($"Queing notification for stream {item.UserName} type {item.Type} started at {item.StartedAt}"); 129 | queue.Add(item); 130 | } 131 | 132 | Log.LogInformation("Processing complete"); 133 | return new OkResult(); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/functions/TwitterEventHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Azure.WebJobs; 4 | using Microsoft.Extensions.Logging; 5 | using TwitchLib.Webhook.Models; 6 | 7 | namespace Markekraus.TwitchStreamNotifications 8 | { 9 | // Borrowing from https://markheath.net/post/randomly-scheduled-tweets-azure-functions 10 | public static class TwitterEventHandler 11 | { 12 | private readonly static string TwitterTweetTemplate = Environment.GetEnvironmentVariable("TwitterTweetTemplate"); 13 | 14 | [FunctionName("TwitterEventHandler")] 15 | public static async Task Run( 16 | [QueueTrigger("%TwitterNotifications%", Connection = "TwitchStreamStorage")] Stream StreamEvent, 17 | ILogger log) 18 | { 19 | log.LogInformation($"TwitterEventHandler processing: {StreamEvent.UserName} type {StreamEvent.Type} started at {StreamEvent.StartedAt}"); 20 | 21 | if (StreamEvent.Type != "live") 22 | { 23 | log.LogInformation($"TwitterEventHandler Processing event skipped. type: {StreamEvent.Type}"); 24 | return; 25 | } 26 | 27 | 28 | string username; 29 | if (string.IsNullOrWhiteSpace(StreamEvent.Subscription.TwitterName)) 30 | { 31 | username = StreamEvent.UserName; 32 | log.LogInformation($"TwitterEventHandler Stream username {username} will be used"); 33 | } 34 | else 35 | { 36 | username = $"@{StreamEvent.Subscription.TwitterName}"; 37 | log.LogInformation($"TwitterEventHandler Twitter username {username} will be used"); 38 | } 39 | 40 | var game = await StreamEvent.GetGameName(log); 41 | log.LogInformation($"TwitterEventHandler Twitter game {game} will be used"); 42 | 43 | string streamUri = $"https://twitch.tv/{StreamEvent.UserName}"; 44 | log.LogInformation($"TwitterEventHandler Stream Uri: {streamUri}"); 45 | 46 | string myTweet = string.Format(TwitterTweetTemplate, streamUri, username, DateTime.UtcNow.ToString("u"), game); 47 | 48 | await TwitterClient.PublishTweet(myTweet, log); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/functions/TwitterScheduledEventNotifier.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Azure.WebJobs; 4 | using Microsoft.Extensions.Logging; 5 | using Markekraus.TwitchStreamNotifications.Models; 6 | 7 | namespace Markekraus.TwitchStreamNotifications 8 | { 9 | public static class TwitterScheduledEventNotifier 10 | { 11 | private readonly static string TwitterTweetTemplate = Environment.GetEnvironmentVariable("TwitterScheduledEventTweetTemplate"); 12 | 13 | [FunctionName("TwitterScheduledEventNotifier")] 14 | public static async Task Run( 15 | [QueueTrigger("%TwitterEventNotificationsQueue%", Connection = "TwitchStreamStorage")] TwitchScheduledChannelEvent ScheduledEvent, 16 | ILogger log) 17 | { 18 | log.LogInformation($"TwitterScheduledEventNotifier function processed: TwitchName {ScheduledEvent.EventItem.Subscription.TwitchName} TwitterName {ScheduledEvent.EventItem.Subscription.TwitterName} EventID {ScheduledEvent.EventItem.Event.Id} NotificationType {ScheduledEvent.Type}"); 19 | 20 | var subscription = ScheduledEvent.EventItem.Subscription; 21 | var channelEvent = ScheduledEvent.EventItem.Event; 22 | var eventType = ScheduledEvent.Type; 23 | 24 | if (eventType == TwitchScheduledChannelEventType.Unknown) 25 | { 26 | log.LogInformation($"TwitterScheduledEventNotifier Processing event skipped. TwitchName {ScheduledEvent.EventItem.Subscription.TwitchName} TwitterName {ScheduledEvent.EventItem.Subscription.TwitterName} EventID {ScheduledEvent.EventItem.Event.Id} NotificationType {ScheduledEvent.Type}"); 27 | return; 28 | } 29 | 30 | string username; 31 | if (string.IsNullOrWhiteSpace(subscription.TwitterName) || subscription.TwitterName == Utility.NameNullString) 32 | { 33 | username = subscription.TwitchName; 34 | log.LogInformation($"TwitterScheduledEventNotifier Stream username {username} will be used"); 35 | } 36 | else 37 | { 38 | username = $"@{subscription.TwitterName}"; 39 | log.LogInformation($"TwitterScheduledEventNotifier Twitter username {username} will be used"); 40 | } 41 | 42 | string eventUri = $"https://www.twitch.tv/events/{channelEvent.Id}"; 43 | log.LogInformation($"TwitterScheduledEventNotifier Event Uri: {eventUri}"); 44 | 45 | string myTweet = string.Format( 46 | TwitterTweetTemplate, 47 | eventUri, 48 | username, 49 | Utility.TypeStringLookup[eventType], 50 | channelEvent.StartTime.ToString("u"), 51 | channelEvent.Title); 52 | 53 | await TwitterClient.PublishTweet(myTweet, log); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": false, 7 | "maxTelemetryItemsPerSecond" : 100, 8 | "excludedTypes": "Request;Exception" 9 | } 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/utilities/DiscordClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.WebUtilities; 4 | using System.Security.Cryptography; 5 | using System.IO; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using System.Threading; 9 | using Microsoft.Extensions.Logging; 10 | using System.Net.Http; 11 | using Markekraus.TwitchStreamNotifications.Models; 12 | using Newtonsoft.Json; 13 | 14 | namespace Markekraus.TwitchStreamNotifications 15 | { 16 | public static class DiscordClient 17 | { 18 | private readonly static string DiscordWebhookUri = Environment.GetEnvironmentVariable("DiscordWebhookUri"); 19 | private const int MaxMessageSize = 2000; 20 | private static HttpClient client = new HttpClient(); 21 | 22 | public static async Task SendDiscordMessageAsync(DiscordMessage Message, ILogger log) 23 | { 24 | log.LogInformation($"SendDiscordMessageAsync DiscordMessage: {Message.Content}"); 25 | 26 | if (Message.Content.Length >= MaxMessageSize) 27 | { 28 | log.LogError($"SendDiscordMessageAsync Discord messages is {Message.Content.Length} long and exceeds the {MaxMessageSize} max length."); 29 | return null; 30 | } 31 | 32 | if (Environment.GetEnvironmentVariable(Utility.DISABLE_NOTIFICATIONS).ToLower() == "true") 33 | { 34 | log.LogInformation("SendDiscordMessageAsync Notifications are disabled. exiting"); 35 | return null; 36 | } 37 | 38 | var httpMessageBody = JsonConvert.SerializeObject(Message); 39 | log.LogInformation("SendDiscordMessageAsync HttpMessageBody:"); 40 | log.LogInformation(httpMessageBody); 41 | 42 | var httpMessage = new HttpRequestMessage() 43 | { 44 | RequestUri = new Uri(DiscordWebhookUri), 45 | Content = new StringContent(httpMessageBody, Encoding.UTF8, Utility.ApplicationJsonContentType), 46 | Method = HttpMethod.Post 47 | }; 48 | 49 | var httpResponse = await client.SendAsync(httpMessage, HttpCompletionOption.ResponseHeadersRead); 50 | 51 | if (!httpResponse.IsSuccessStatusCode) 52 | { 53 | log.LogError($"SendDiscordMessageAsync Request Failed"); 54 | } 55 | log.LogInformation($"SendDiscordMessageAsync Success: {httpResponse.IsSuccessStatusCode}"); 56 | log.LogInformation($"SendDiscordMessageAsync StatusCode: {httpResponse.StatusCode}"); 57 | log.LogInformation($"SendDiscordMessageAsync ReasonPhrase: {httpResponse.ReasonPhrase}"); 58 | 59 | var responseBody = await httpResponse.Content.ReadAsStringAsync(); 60 | log.LogInformation($"SendDiscordMessageAsync Response:"); 61 | log.LogInformation(responseBody); 62 | 63 | return httpResponse; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/utilities/TwitchClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Net.Http; 5 | using System.Net.Http.Headers; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using Markekraus.TwitchStreamNotifications.Models; 9 | using Newtonsoft.Json; 10 | using System.Linq; 11 | using Microsoft.Extensions.Logging; 12 | using Microsoft.Azure.WebJobs; 13 | 14 | namespace Markekraus.TwitchStreamNotifications 15 | { 16 | public static class TwitchClient 17 | { 18 | static private HttpClient client = new HttpClient(); 19 | static private readonly string clientId = Environment.GetEnvironmentVariable("TwitchClientId"); 20 | static private readonly string clientSecret = Environment.GetEnvironmentVariable("TwitchClientSecret"); 21 | static private readonly string clientRedirectUri = Environment.GetEnvironmentVariable("TwitchClientRedirectUri"); 22 | private static readonly string HubSecret = Environment.GetEnvironmentVariable("TwitchSubscriptionsHashSecret"); 23 | private static readonly string TwitchWebhookBaseUri = Environment.GetEnvironmentVariable("TwitchWebhookBaseUri"); 24 | 25 | private const string TwitchOAuthBaseUri = "https://id.twitch.tv/oauth2/token"; 26 | private const string TwitchUsersEndpointUri = "https://api.twitch.tv/helix/users"; 27 | private const string TwitchStreamsEndpointUri = "https://api.twitch.tv/helix/streams"; 28 | private const string TwitchWebhooksHubEndpointUri = "https://api.twitch.tv/helix/webhooks/hub"; 29 | private const string TwitchWebhooksSubscriptionsEndpointUri = "https://api.twitch.tv/helix/webhooks/subscriptions"; 30 | private const string TwitchChannelEventUriTemplate = "https://api.twitch.tv/v5/channels/{0}/events"; 31 | private const string TwitchGamesEndpointUri = "https://api.twitch.tv/helix/games"; 32 | private const string ClientIdHeaderName = "Client-ID"; 33 | 34 | private enum TwitchSubscriptionMode 35 | { 36 | Subscribe, 37 | Unsubscribe 38 | } 39 | 40 | private static async Task GetOAuthResponse(ILogger Log) 41 | { 42 | Log.LogInformation("GetOAuthResponse Begin"); 43 | var dict = new Dictionary(); 44 | dict.Add("client_id", clientId); 45 | dict.Add("client_secret", clientSecret); 46 | dict.Add("grant_type", "client_credentials"); 47 | 48 | var requestUri = TwitchOAuthBaseUri; 49 | Log.LogInformation($"RequestUri: {requestUri}"); 50 | 51 | var message = new HttpRequestMessage() 52 | { 53 | Content = new FormUrlEncodedContent(dict), 54 | Method = HttpMethod.Post, 55 | RequestUri = new Uri(requestUri) 56 | }; 57 | message.Headers.TryAddWithoutValidation("Accept", Utility.ApplicationJsonContentType); 58 | var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead); 59 | LogHttpResponse(response, "GetOAuthResponse", Log); 60 | 61 | 62 | var responseBody = await response.Content.ReadAsStringAsync(); 63 | if (!response.IsSuccessStatusCode) 64 | { 65 | Log.LogInformation("ResponseBody:"); 66 | Log.LogInformation(responseBody); 67 | Log.LogInformation("GetOAuthResponse End"); 68 | return null; 69 | } 70 | else 71 | { 72 | Log.LogInformation("GetOAuthResponse End"); 73 | return JsonConvert.DeserializeObject(responseBody); 74 | } 75 | } 76 | 77 | public static async Task> GetTwitchWebhookSubscriptions(ILogger Log) 78 | { 79 | Log.LogInformation("GetTwitchWebhookSubscriptions Begin"); 80 | 81 | var requestUri = $"{TwitchWebhooksSubscriptionsEndpointUri}?first=100"; 82 | Log.LogInformation($"RequestUri: {requestUri}"); 83 | 84 | var authToken = await GetOAuthResponse(Log); 85 | 86 | var message = new HttpRequestMessage() 87 | { 88 | Method = HttpMethod.Get, 89 | RequestUri = new Uri(requestUri) 90 | }; 91 | message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken.AccessToken); 92 | message.Headers.TryAddWithoutValidation(ClientIdHeaderName, clientId); 93 | 94 | var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead); 95 | 96 | LogHttpResponse(response, "GetTwitchWebhookSubscriptions", Log); 97 | 98 | var responseBody = await response.Content.ReadAsStringAsync(); 99 | if (!response.IsSuccessStatusCode) 100 | { 101 | Log.LogInformation("GetTwitchWebhookSubscriptions End"); 102 | return null; 103 | } 104 | else 105 | { 106 | var subscriptionData = JsonConvert.DeserializeObject(responseBody); 107 | return subscriptionData.Data; 108 | } 109 | } 110 | 111 | private static async Task GetTwitchStreamUserId(string TwitchName, ILogger Log) 112 | { 113 | Log.LogInformation("GetTwitchStreamUserId Begin"); 114 | 115 | var requestUri = $"{TwitchUsersEndpointUri}?login={WebUtility.UrlEncode(TwitchName)}"; 116 | Log.LogInformation($"RequestUri: {requestUri}"); 117 | 118 | var authToken = await GetOAuthResponse(Log); 119 | var message = new HttpRequestMessage() 120 | { 121 | Method = HttpMethod.Get, 122 | RequestUri = new Uri(requestUri) 123 | }; 124 | message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken.AccessToken); 125 | message.Headers.TryAddWithoutValidation(ClientIdHeaderName, clientId); 126 | 127 | var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead); 128 | 129 | LogHttpResponse(response, "GetTwitchStreamUserId", Log); 130 | 131 | if (!response.IsSuccessStatusCode) 132 | { 133 | Log.LogInformation("GetTwitchStreamUserId End"); 134 | return null; 135 | } 136 | else 137 | { 138 | var responseBody = await response.Content.ReadAsStringAsync(); 139 | Log.LogInformation("ResponseBody:"); 140 | Log.LogInformation(responseBody); 141 | 142 | var userData = JsonConvert.DeserializeObject(responseBody); 143 | 144 | Log.LogInformation("GetTwitchStreamUserId End"); 145 | return userData.Data.FirstOrDefault().Id; 146 | } 147 | } 148 | 149 | public static async Task SubscribeTwitchStreamWebhook(TwitchSubscription Subscription, ILogger Log) 150 | { 151 | Log.LogInformation("SubscribeTwitchStreamWebhook Begin"); 152 | await SubscriptionActionTwitchStreamWebhook(Subscription, TwitchSubscriptionMode.Subscribe, Log); 153 | Log.LogInformation("SubscribeTwitchStreamWebhook End"); 154 | } 155 | 156 | public static async Task UnsubscribeTwitchStreamWebhook(TwitchSubscription Subscription, ILogger Log) 157 | { 158 | Log.LogInformation("UnsubscribeTwitchStreamWebhook Begin"); 159 | await SubscriptionActionTwitchStreamWebhook(Subscription, TwitchSubscriptionMode.Unsubscribe, Log); 160 | Log.LogInformation("UnsubscribeTwitchStreamWebhook End"); 161 | } 162 | 163 | private static async Task SubscriptionActionTwitchStreamWebhook(TwitchSubscription Subscription, TwitchSubscriptionMode SubscriptionMode, ILogger Log) 164 | { 165 | Log.LogInformation("SubscriptionActionTwitchStreamWebhook Begin"); 166 | Log.LogInformation($"TwitchName: {Subscription.TwitchName}"); 167 | Log.LogInformation($"TwitterName: {Subscription.TwitterName}"); 168 | Log.LogInformation($"DiscordName: {Subscription.DiscordName}"); 169 | 170 | var userId = await GetTwitchStreamUserId(Subscription.TwitchName, Log); 171 | Log.LogInformation($"UserID: {userId}"); 172 | 173 | var twitterPart = string.IsNullOrWhiteSpace(Subscription.TwitterName) ? Utility.NameNullString : Subscription.TwitterName; 174 | var discordPart = string.IsNullOrWhiteSpace(Subscription.DiscordName) ? Utility.NameNullString : Subscription.DiscordName; 175 | var callbackUri = $"{TwitchWebhookBaseUri}/{Subscription.TwitchName}/{twitterPart}/{discordPart}"; 176 | Log.LogInformation($"CallbackUri: {callbackUri}"); 177 | 178 | var hubTopic = $"{TwitchStreamsEndpointUri}?user_id={userId}"; 179 | Log.LogInformation($"HubTopic: {hubTopic}"); 180 | 181 | var hubSubscription = new TwitchHubSubscription() 182 | { 183 | HubMode = SubscriptionMode.ToString().ToLower(), 184 | HubSecret = HubSecret, 185 | HubTopic = hubTopic, 186 | HubCallback = callbackUri, 187 | HubLeaseSeconds = 864000 188 | }; 189 | 190 | var requestBody = JsonConvert.SerializeObject(hubSubscription); 191 | 192 | var authToken = await GetOAuthResponse(Log); 193 | var message = new HttpRequestMessage() 194 | { 195 | Content = new StringContent(requestBody, Encoding.UTF8, Utility.ApplicationJsonContentType), 196 | Method = HttpMethod.Post, 197 | RequestUri = new Uri(TwitchWebhooksHubEndpointUri) 198 | }; 199 | message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken.AccessToken); 200 | message.Headers.TryAddWithoutValidation(ClientIdHeaderName, clientId); 201 | 202 | var response = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead); 203 | 204 | LogHttpResponse(response, "SubscriptionActionTwitchStreamWebhook", Log); 205 | 206 | var responseBody = await response.Content.ReadAsStringAsync(); 207 | Log.LogInformation($"Response: {responseBody}"); 208 | 209 | Log.LogInformation("SubscriptionActionTwitchStreamWebhook End"); 210 | } 211 | 212 | private static void LogHttpResponse(HttpResponseMessage Response, string Operation, ILogger Log) 213 | { 214 | if (!Response.IsSuccessStatusCode) 215 | { 216 | Log.LogError($"{Operation} Request Failed"); 217 | } 218 | Log.LogInformation($"Success: {Response.IsSuccessStatusCode}"); 219 | Log.LogInformation($"StatusCode: {Response.StatusCode}"); 220 | Log.LogInformation($"ReasonPhrase: {Response.ReasonPhrase}"); 221 | } 222 | 223 | public static async Task GetTwitchSubscriptionEvents(TwitchSubscription Subscription, ILogger Log) 224 | { 225 | Log.LogInformation("GetTwitchSubscriptionEvents Begin"); 226 | Log.LogInformation($"GetTwitchSubscriptionEvents TwitchName: {Subscription.TwitchName}"); 227 | Log.LogInformation($"GetTwitchSubscriptionEvents TwitterName: {Subscription.TwitterName}"); 228 | Log.LogInformation($"GetTwitchSubscriptionEvents DiscordName: {Subscription.DiscordName}"); 229 | 230 | var userId = await GetTwitchStreamUserId(Subscription.TwitchName, Log); 231 | Log.LogInformation($"GetTwitchSubscriptionEvents UserID: {userId}"); 232 | 233 | var requestUri = string.Format(TwitchChannelEventUriTemplate, userId); 234 | Log.LogInformation($"GetTwitchSubscriptionEvents RequestUri: {requestUri}"); 235 | 236 | var message = new HttpRequestMessage() 237 | { 238 | Method = HttpMethod.Get, 239 | RequestUri = new Uri(requestUri) 240 | }; 241 | message.Headers.TryAddWithoutValidation(ClientIdHeaderName, clientId); 242 | 243 | var httpResponse = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead); 244 | 245 | LogHttpResponse(httpResponse, "GetTwitchSubscriptionEvents", Log); 246 | 247 | if (!httpResponse.IsSuccessStatusCode) 248 | { 249 | Log.LogInformation("GetTwitchSubscriptionEvents End"); 250 | return null; 251 | } 252 | else 253 | { 254 | var responseBody = await httpResponse.Content.ReadAsStringAsync(); 255 | Log.LogInformation("GetTwitchSubscriptionEvents ResponseBody:"); 256 | Log.LogInformation(responseBody); 257 | 258 | var response = JsonConvert.DeserializeObject(responseBody); 259 | 260 | Log.LogInformation("GetTwitchStreamUserId End"); 261 | return response; 262 | } 263 | } 264 | 265 | public static async Task InvokeSubscriptionRegistration( 266 | IList TwitchSubscriptions, 267 | IAsyncCollector SubscribeQueue, 268 | IAsyncCollector UnsubscribeQueue, 269 | ILogger log, 270 | string CallingFunction) 271 | { 272 | var logPrefix = $"{CallingFunction}.{nameof(InvokeSubscriptionRegistration)}"; 273 | 274 | var response = new TwitchSubscriptionRegistrationResponse(); 275 | response.RequestSubscriptions = TwitchSubscriptions; 276 | 277 | log.LogInformation($"{logPrefix} Get current subscriptions"); 278 | var currentSubscriptions = await GetTwitchWebhookSubscriptions(log); 279 | response.CurrentSubscriptions = currentSubscriptions; 280 | 281 | log.LogInformation($"{logPrefix} Create currentSubDictionary dictionary"); 282 | var currentSubDictionary = new Dictionary, TwitchSubscription>(); 283 | var currentWebhookSubDictionary = new Dictionary, TwitchWebhookSubscription>(); 284 | string currentTwittername; 285 | string currentDiscordname; 286 | string currentTwitchname; 287 | foreach (var subscription in currentSubscriptions) 288 | { 289 | currentTwittername = string.IsNullOrWhiteSpace(subscription.Subscription.TwitterName) ? Utility.NameNullString : subscription.Subscription.TwitterName.ToLower(); 290 | currentDiscordname = string.IsNullOrWhiteSpace(subscription.Subscription.DiscordName) ? Utility.NameNullString : subscription.Subscription.DiscordName.ToLower(); 291 | currentTwitchname = subscription.Subscription.TwitchName.ToLower(); 292 | log.LogInformation($"{logPrefix} Add TwitchName {subscription.Subscription.TwitchName} TwitterName {subscription.Subscription.TwitterName} DiscordName {subscription.Subscription.DiscordName}"); 293 | var keyTuple = new Tuple(currentTwitchname, currentTwittername, currentDiscordname); 294 | currentSubDictionary.Add( 295 | keyTuple, 296 | subscription.Subscription); 297 | currentWebhookSubDictionary.Add(keyTuple, subscription); 298 | } 299 | 300 | log.LogInformation($"{logPrefix} Create requestedSubDictionary dictionary"); 301 | var requestedSubDictionary = new Dictionary, TwitchSubscription>(); 302 | foreach (var subscription in TwitchSubscriptions) 303 | { 304 | currentTwittername = string.IsNullOrWhiteSpace(subscription.TwitterName) ? Utility.NameNullString : subscription.TwitterName.ToLower(); 305 | currentDiscordname = string.IsNullOrWhiteSpace(subscription.DiscordName) ? Utility.NameNullString : subscription.DiscordName.ToLower(); 306 | currentTwitchname = subscription.TwitchName.ToLower(); 307 | log.LogInformation($"{logPrefix} Add TwitchName {subscription.TwitchName} TwitterName {subscription.TwitterName} DiscordName {subscription.DiscordName}"); 308 | requestedSubDictionary.Add( 309 | new Tuple(currentTwitchname, currentTwittername, currentDiscordname), 310 | subscription); 311 | } 312 | 313 | log.LogInformation($"{logPrefix} Find missing subscriptions to add"); 314 | response.AddSubscriptions = new List(); 315 | foreach (var missing in Enumerable.Except(requestedSubDictionary.Keys, currentSubDictionary.Keys)) 316 | { 317 | log.LogInformation($"{logPrefix} Add Queue TwitchName {requestedSubDictionary[missing].TwitchName} TwitterName {requestedSubDictionary[missing].TwitterName}"); 318 | await SubscribeQueue.AddAsync(requestedSubDictionary[missing]); 319 | response.AddSubscriptions.Add(requestedSubDictionary[missing]); 320 | } 321 | 322 | log.LogInformation($"{logPrefix} Find extra subscriptions to remove"); 323 | response.RemoveSubscriptions = new List(); 324 | foreach (var extra in Enumerable.Except(currentSubDictionary.Keys, requestedSubDictionary.Keys)) 325 | { 326 | log.LogInformation($"{logPrefix} Remove Queue TwitchName {currentSubDictionary[extra].TwitchName} TwitterName {currentSubDictionary[extra].TwitterName}"); 327 | await UnsubscribeQueue.AddAsync(currentSubDictionary[extra]); 328 | response.RemoveSubscriptions.Add(currentSubDictionary[extra]); 329 | } 330 | 331 | var renewalableKeyList = Enumerable.Intersect(requestedSubDictionary.Keys, currentWebhookSubDictionary.Keys); 332 | response.RenewSubscriptions = new List(); 333 | foreach (var renewableKey in renewalableKeyList) 334 | { 335 | var renewableSub = currentWebhookSubDictionary[renewableKey]; 336 | if (DateTime.Parse(renewableSub.ExpiresAt).ToUniversalTime() <= DateTime.UtcNow.AddHours(1)) 337 | { 338 | log.LogInformation($"{logPrefix} Renew Queue TwitchName {renewableSub.Subscription.TwitchName} TwitterName {renewableSub.Subscription.TwitterName}"); 339 | await SubscribeQueue.AddAsync(renewableSub.Subscription); 340 | response.RenewSubscriptions.Add(renewableSub.Subscription); 341 | } 342 | } 343 | 344 | return response; 345 | } 346 | 347 | public static async Task GetGame(string GameId, ILogger Log) 348 | { 349 | Log.LogInformation("GetGame Begin"); 350 | Log.LogInformation($"GetGame GameId: {GameId}"); 351 | 352 | var requestUri = $"{TwitchGamesEndpointUri}?id={GameId}"; 353 | Log.LogInformation($"GetGame RequestUri: {requestUri}"); 354 | 355 | var authToken = await GetOAuthResponse(Log); 356 | var message = new HttpRequestMessage() 357 | { 358 | Method = HttpMethod.Get, 359 | RequestUri = new Uri(requestUri) 360 | }; 361 | message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken.AccessToken); 362 | message.Headers.TryAddWithoutValidation(ClientIdHeaderName, clientId); 363 | 364 | var httpResponse = await client.SendAsync(message, HttpCompletionOption.ResponseContentRead); 365 | 366 | LogHttpResponse(httpResponse, "GetGame", Log); 367 | 368 | if (!httpResponse.IsSuccessStatusCode) 369 | { 370 | Log.LogInformation("GetGame End"); 371 | return null; 372 | } 373 | else 374 | { 375 | var responseBody = await httpResponse.Content.ReadAsStringAsync(); 376 | Log.LogInformation("GetGame ResponseBody:"); 377 | Log.LogInformation(responseBody); 378 | 379 | var response = JsonConvert.DeserializeObject(responseBody).Data.First(); 380 | 381 | Log.LogInformation("GetGame End"); 382 | return response; 383 | } 384 | } 385 | } 386 | } -------------------------------------------------------------------------------- /src/utilities/TwitterClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.Logging; 4 | using Tweetinvi.Exceptions; 5 | using Tweetinvi.Models; 6 | 7 | namespace Markekraus.TwitchStreamNotifications 8 | { 9 | public static class TwitterClient 10 | { 11 | private readonly static string ConsumerKey = Environment.GetEnvironmentVariable("TwitterConsumerKey"); 12 | private readonly static string ConsumerSecret = Environment.GetEnvironmentVariable("TwitterConsumerSecret"); 13 | private readonly static string AccessToken = Environment.GetEnvironmentVariable("TwitterAccessToken"); 14 | private readonly static string AccessTokenSecret = Environment.GetEnvironmentVariable("TwitterAccessTokenSecret"); 15 | private readonly static string TwitterTweetTemplate = Environment.GetEnvironmentVariable("TwitterTweetTemplate"); 16 | public const int MaxTweetLength = 280; 17 | 18 | public static async Task PublishTweet(string TweetMessage, ILogger log) 19 | { 20 | log.LogInformation($"PublishTweet Tweet: {TweetMessage}"); 21 | 22 | if (TweetMessage.Length > MaxTweetLength) 23 | { 24 | log.LogWarning($"PublishTweet Tweet too long {TweetMessage.Length} max {MaxTweetLength}"); 25 | } 26 | 27 | if (Environment.GetEnvironmentVariable(Utility.DISABLE_NOTIFICATIONS).ToLower() == "true") 28 | { 29 | log.LogInformation("PublishTweet Notifications are disabled. exiting"); 30 | return null; 31 | } 32 | 33 | try 34 | { 35 | var tweetinvi = new Tweetinvi.TwitterClient(ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret); 36 | var publishedTweet = await tweetinvi.Tweets.PublishTweetAsync(TweetMessage); 37 | log.LogInformation($"PublishTweet published tweet {publishedTweet.Id}"); 38 | return publishedTweet; 39 | } 40 | catch (TwitterException e) 41 | { 42 | log.LogError($"Failed to tweet: {e.ToString()}"); 43 | } 44 | catch (Exception e) 45 | { 46 | log.LogError($"Unhandled error when sending tweet: {e.ToString()}"); 47 | throw; 48 | } 49 | return null; 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/utilities/Utility.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.WebUtilities; 4 | using System.Security.Cryptography; 5 | using System.IO; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using System.Threading; 9 | using Microsoft.Extensions.Logging; 10 | using System.Net.Http; 11 | using Markekraus.TwitchStreamNotifications.Models; 12 | using System.Collections.Generic; 13 | 14 | namespace Markekraus.TwitchStreamNotifications 15 | { 16 | public static class Utility 17 | { 18 | public const string NameNullString = "--"; 19 | public const string DISABLE_NOTIFICATIONS = "DISABLE_NOTIFICATIONS"; 20 | public const string ApplicationJsonContentType = "application/json"; 21 | 22 | public readonly static Dictionary TypeStringLookup = new Dictionary(){ 23 | {TwitchScheduledChannelEventType.Unknown, "unknown"}, 24 | {TwitchScheduledChannelEventType.Hour, "an hour"}, 25 | {TwitchScheduledChannelEventType.Day, "a day"}, 26 | {TwitchScheduledChannelEventType.Week, "a week"} 27 | }; 28 | 29 | public static async Task ComputeRequestBodySha256HashAsync( 30 | HttpRequest request, 31 | string secret) 32 | { 33 | await PrepareRequestBody(request); 34 | var secretBytes = Encoding.UTF8.GetBytes(secret); 35 | 36 | using (HMACSHA256 hasher = new HMACSHA256(secretBytes)) 37 | { 38 | try 39 | { 40 | Stream inputStream = request.Body; 41 | 42 | int bytesRead; 43 | byte[] buffer = new byte[4096]; 44 | 45 | while ((bytesRead = await inputStream.ReadAsync(buffer, 0, buffer.Length)) > 0) 46 | { 47 | hasher.TransformBlock(buffer, inputOffset: 0, inputCount: bytesRead, 48 | outputBuffer: null, outputOffset: 0); 49 | } 50 | 51 | hasher.TransformFinalBlock(Array.Empty(), inputOffset: 0, inputCount: 0); 52 | 53 | return hasher.Hash; 54 | } 55 | finally 56 | { 57 | request.Body.Seek(0L, SeekOrigin.Begin); 58 | } 59 | } 60 | } 61 | 62 | public static async Task PrepareRequestBody(HttpRequest request) 63 | { 64 | if (!request.Body.CanSeek) 65 | { 66 | request.EnableBuffering(); 67 | await StreamHelperExtensions.DrainAsync(request.Body, CancellationToken.None); 68 | } 69 | 70 | request.Body.Seek(0L, SeekOrigin.Begin); 71 | } 72 | 73 | public static byte[] FromHex(string content) 74 | { 75 | if (string.IsNullOrEmpty(content)) 76 | { 77 | return Array.Empty(); 78 | } 79 | 80 | try 81 | { 82 | var data = new byte[content.Length / 2]; 83 | var input = 0; 84 | for (var output = 0; output < data.Length; output++) 85 | { 86 | data[output] = Convert.ToByte(new string(new char[2] { content[input++], content[input++] }), 16); 87 | } 88 | 89 | if (input != content.Length) 90 | { 91 | return null; 92 | } 93 | 94 | return data; 95 | } 96 | catch (Exception exception) when (exception is ArgumentException || exception is FormatException) 97 | { 98 | return null; 99 | } 100 | } 101 | 102 | public static bool SecretEqual(byte[] inputA, byte[] inputB) 103 | { 104 | if (ReferenceEquals(inputA, inputB)) 105 | { 106 | return true; 107 | } 108 | 109 | if (inputA == null || inputB == null || inputA.Length != inputB.Length) 110 | { 111 | return false; 112 | } 113 | 114 | var areSame = true; 115 | for (var i = 0; i < inputA.Length; i++) 116 | { 117 | areSame &= inputA[i] == inputB[i]; 118 | } 119 | 120 | return areSame; 121 | } 122 | } 123 | } --------------------------------------------------------------------------------