├── .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 | }
--------------------------------------------------------------------------------