diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1f1dac1..0d71e9d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,7 +8,7 @@ on: - 'src/**' jobs: build: - name: Build Docker Image + name: Build Docker Images runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -25,14 +25,27 @@ jobs: run: echo "version=$(date +%Y.%m.%d.%H%M%S)" >> $GITHUB_OUTPUT - name: Build Docker image run: | - TAG=${{ secrets.DOCKERHUB_USERNAME }}/steves-bot:${{ steps.version.outputs.version }} - docker build -t $TAG src - docker push $TAG + WORKER_TAG=${{ secrets.DOCKERHUB_USERNAME }}/steves-bot:worker-${{ steps.version.outputs.version }} + docker build -f src/StevesBot.Worker.Dockerfile -t $WORKER_TAG src + docker push $WORKER_TAG + WEBHOOK_TAG=${{ secrets.DOCKERHUB_USERNAME }}/steves-bot:webhook-${{ steps.version.outputs.version }} + docker build -f src/StevesBot.Webhook.Dockerfile -t $WEBHOOK_TAG src + docker push $WEBHOOK_TAG deploy: - name: Deploy Docker Image + name: Deploy Docker Images runs-on: ubuntu-latest needs: build steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Copy compose file to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + source: src/compose.yml + target: steves-bot - name: Run image on server uses: appleboy/ssh-action@v1 with: @@ -42,25 +55,32 @@ jobs: script: | cd steves-bot - echo 'Logging__LogLevel__Default=Debug' >> .env - echo 'Logging__LogLevel__Microsoft=Debug' >> .env - echo 'Logging__LogLevel__Microsoft.Hosting.Lifetime=Debug' >> .env + echo 'WORKER_VERSION=worker-${{ needs.build.outputs.version }}' > .env + echo 'WEBHOOK_VERSION=webhook-${{ needs.build.outputs.version }}' > .env + echo 'DiscordClientOptions__ApiUrl=${{ vars.DISCORD_API_URL }}' >> .env echo 'DiscordClientOptions__AppToken=${{ secrets.DISCORD_APP_TOKEN }}' >> .env - echo 'DiscordClientOptions__Intents=${{ vars.DISCORD_INTENTS }}' >> .env - echo 'SeqOptions__ServerUrl=${{ secrets.SEQ_SERVER_URL }}' >> .env - echo 'SeqOptions__ApiKeyHeader=${{ vars.SEQ_API_KEY_HEADER }}' >> .env - echo 'SeqOptions__ApiKey=${{ secrets.SEQ_API_KEY }}' >> .env - echo 'DOTNET_ENVIRONMENT=Production' >> .env + echo 'DiscordClientOptions__Intents=${{ vars.DISCORD_INTENTS }}' >> env - docker stop steves-bot - docker rm steves-bot - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/steves-bot:${{ needs.build.outputs.version }} - docker run \ - --name steves-bot \ - --restart always \ - --env-file .env \ - -d \ - ${{ secrets.DOCKERHUB_USERNAME }}/steves-bot:${{ needs.build.outputs.version }} - - rm .env \ No newline at end of file + echo 'SEQ_SERVER_URL=${{ secrets.SEQ_SERVER_URL }}' >> .env + echo 'SEQ_API_KEY_HEADER=${{ vars.SEQ_API_KEY_HEADER }}' >> .env + echo 'SEQ_WORKER_API_KEY=${{ secrets.SEQ_WORKER_API_KEY }}' >> .env + echo 'SEQ_WEBHOOK_API_KEY=${{ secrets.SEQ_WEBHOOK_API_KEY }}' >> .env + + echo 'YOUTUBE_BASE_URL=${{ vars.YOUTUBE_BASE_URL }}' >> .env + echo 'YOUTUBE_API_KEY=${{ secrets.YOUTUBE_API_KEY }}' >> .env + + echo 'SUBSCRIPTION_CALLBACK_URL=${{ secrets.SUBSCRIPTION_CALLBACK_URL }}' >> .env + echo 'SUBSCRIPTION_TOPIC_URL=${{ secrets.SUBSCRIPTION_TOPIC_URL }}' >> .env + + echo 'PUBSUB_BASE_URL=${{ vars.PUBSUB_BASE_URL }}' >> .env + + echo 'DISCORD_NOTIFICATION_CHANNEL_ID=${{ vars.DISCORD_NOTIFICATION_CHANNEL_ID }}' >> .env + echo 'DISCORD_NOTIFICATION_MESSAGE_FORMAT="${{ vars.DISCORD_NOTIFICATION_MESSAGE_FORMAT }}"' >> .env + + docker compose -f compose.yml down + docker compose -f compose.yml pull + docker compose -f compose.yml up -d --wait + + rm compose.yml + rm .env diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index bf79a98..6e685ba 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -33,8 +33,10 @@ jobs: run: dotnet tool install -g dotnet-reportgenerator-globaltool - name: Run tests run: dotnet test src + - name: Merge coverage reports + run: reportgenerator "-reports:src/tests/*/TestResults/*/coverage.cobertura.xml" "-reporttypes:Html;Cobertura" "-targetdir:src/tests/TestResults/Coverage" - name: Upload code coverage uses: actions/upload-artifact@v4 with: - name: html-report - path: src/StevesBot.Worker.Tests/TestResults/Coverage/Report/ + name: coverage-reports + path: src/tests/TestResults/Coverage diff --git a/README.md b/README.md index 3c9d534..274080e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,32 @@ # Steve's Bot 🤖 -This is a Discord bot built with .NET 9, designed to be a full-featured and extensible solution for having my own assistant in my [Discord server](https://discord.stevanfreeborn.com). +This is a comprehensive Discord bot platform built with .NET 9, designed to be a full-featured and extensible solution for having my own assistant in my [Discord server](https://discord.stevanfreeborn.com). ## ✨ Features +### Core Bot Platform + - **Custom Discord Gateway Client**: Full-featured implementation with: - WebSocket connection management - Automatic heartbeat handling - Session resumption and reconnection logic - Event-driven architecture +- **Shared Library**: Common Discord REST client and telemetry components - **Observability**: Built-in telemetry with OpenTelemetry support - **Resilient Architecture**: Graceful error handling and automatic recovery -- **Containerized Deployment**: Ready for Docker deployment + +### YouTube Integration + +- **YouTube Webhook Service**: Web API for receiving YouTube notifications +- **Live Stream Detection**: Automatic detection and Discord notifications for live streams +- **PubSubHubbub Integration**: YouTube webhook subscription management +- **Stream Deduplication**: Prevents duplicate notifications using in-memory store + +### Deployment & Operations + +- **Multi-Service Architecture**: Separate worker and webhook services +- **Containerized Deployment**: Docker containers with Docker Compose orchestration +- **Production Ready**: Environment-specific configuration and logging ## 🚀 Quick Start @@ -19,13 +34,16 @@ This is a Discord bot built with .NET 9, designed to be a full-featured and exte - [.NET 9 SDK](https://dotnet.microsoft.com/download) - Discord Bot Token (from [Discord Developer Portal](https://discord.com/developers/applications)) +- For YouTube integration: YouTube Data API v3 key from [Google Cloud Console](https://console.cloud.google.com/apis/credentials) ### Configuration +#### Worker Service (Discord Bot) + 1. Copy the example configuration: ```powershell - Copy-Item src/StevesBot.Worker/appsettings.Example.json src/StevesBot.Worker/appsettings.Development.json + Copy-Item src/src/StevesBot.Worker/appsettings.Example.json src/src/StevesBot.Worker/appsettings.Development.json ``` 2. Update `appsettings.Development.json` with your Discord bot credentials: @@ -40,26 +58,29 @@ This is a Discord bot built with .NET 9, designed to be a full-featured and exte } ``` -### Running the Bot +#### Webhook Service (YouTube Integration) + +1. Copy the example configuration: + + ```powershell + Copy-Item src/src/StevesBot.Webhook/appsettings.Example.json src/src/StevesBot.Webhook/appsettings.Development.json + ``` + +2. Update with your API keys and callback URLs for YouTube integration. + +### Running the Services #### Using VS Code Tasks ```powershell -# Build the project +# Build the entire solution dotnet build src/StevesBot.sln -# Run the bot -dotnet run --project src/StevesBot.Worker -``` +# Run the Discord bot worker +dotnet run --project src/src/StevesBot.Worker -#### Using Docker - -```powershell -# Build the Docker image -docker build -t steves-bot src/ - -# Run the container -docker run -d --name steves-bot steves-bot +# Run the YouTube webhook service (in separate terminal) +dotnet run --project src/src/StevesBot.Webhook ``` ## 🏗️ Architecture @@ -68,28 +89,58 @@ docker run -d --name steves-bot steves-bot ```txt src/ -├── StevesBot.Worker/ # Main bot application -│ ├── Discord/ # Discord client implementation -│ │ ├── Gateway/ # WebSocket gateway client -│ │ ├── Rest/ # REST API client -│ │ └── Shared/ # Common Discord models -│ ├── Handlers/ # Event handlers -│ ├── Telemetry/ # Observability setup -│ ├── Threading/ # Async utilities -│ └── WebSockets/ # WebSocket abstractions -└── StevesBot.Worker.Tests/ # Comprehensive test suite +├── src/ +│ ├── StevesBot.Library/ # Shared library components +│ │ ├── Discord/ # Common Discord REST client +│ │ └── Telemetry/ # Shared telemetry setup +│ ├── StevesBot.Worker/ # Discord bot worker service +│ │ ├── Discord/ # Discord Gateway client implementation +│ │ │ ├── Gateway/ # WebSocket gateway client +│ │ │ ├── Rest/ # REST API client +│ │ │ └── Shared/ # Common Discord models +│ │ ├── Handlers/ # Discord event handlers +│ │ ├── Telemetry/ # Worker-specific telemetry +│ │ ├── Threading/ # Async utilities +│ │ └── WebSockets/ # WebSocket abstractions +│ └── StevesBot.Webhook/ # YouTube webhook service +│ ├── YouTube/ # YouTube integration components +│ │ ├── Data/ # YouTube API models +│ │ ├── Handlers/ # Webhook request handlers +│ │ └── Tasks/ # Background tasks +│ └── Telemetry/ # Webhook-specific telemetry +├── tests/ # Comprehensive test suites +│ ├── StevesBot.Library.Tests/ # Shared library tests +│ ├── StevesBot.Worker.Tests/ # Worker service tests +│ └── StevesBot.Webhook.Tests/ # Webhook service tests +├── compose.yml # Docker Compose configuration +├── StevesBot.Worker.Dockerfile # Worker service container +└── StevesBot.Webhook.Dockerfile # Webhook service container ``` ### Key Components +#### Discord Bot Worker + - **DiscordGatewayClient**: Custom WebSocket client for Discord Gateway API - **Worker**: Background service that manages the bot lifecycle - **WebSocket Management**: Custom WebSocket factory and connection handling - **AsyncLock**: Thread-safe async locking mechanism +#### YouTube Webhook Service + +- **NotificationHandler**: Processes YouTube webhook notifications +- **SubscriptionWorker**: Manages YouTube PubSubHubbub subscriptions +- **YouTubeDataApiClient**: Integrates with YouTube Data API v3 +- **LastPostedStreamStore**: Prevents duplicate stream notifications + +#### Shared Library + +- **DiscordRestClient**: Reusable Discord REST API client +- **Telemetry Infrastructure**: OpenTelemetry setup and instrumentation + ## 🔧 Configuration -### Discord Client Options +### Discord Worker Options | Setting | Description | Required | |----------------------------------|-----------------------------------------|----------| @@ -97,6 +148,17 @@ src/ | `DiscordClientOptions__AppToken` | Bot token from Discord Developer Portal | Yes | | `DiscordClientOptions__Intents` | Discord Gateway intents | Yes | +### YouTube Webhook Options + +| Setting | Description | Required | +|----------------------------------------|---------------------------------------|----------| +| `YouTubeClientOptions__BaseUrl` | YouTube Data API base URL | Yes | +| `YouTubeClientOptions__ApiKey` | YouTube Data API v3 key | Yes | +| `SubscriptionOptions__CallbackUrl` | Webhook callback URL | Yes | +| `SubscriptionOptions__TopicUrl` | YouTube channel topic URL | Yes | +| `PubSubClientOptions__BaseUrl` | PubSubHubbub hub URL | Yes | +| `DiscordNotificationOptions__ChannelId` | Discord channel for notifications | Yes | + ### Telemetry Options | Setting | Description | Required | @@ -112,9 +174,6 @@ The project includes a comprehensive test suite with both unit and integration t ```powershell # Run all tests dotnet test src/StevesBot.sln - -# Run with coverage -dotnet test src/StevesBot.sln --collect:"XPlat Code Coverage" ``` ### Test Coverage diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c78f721..807df2c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,6 @@ - $(MSBuildThisFileDirectory) net9.0 enable enable diff --git a/src/Dockerfile b/src/Dockerfile deleted file mode 100644 index 83404cb..0000000 --- a/src/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS base -WORKDIR /app -COPY ./StevesBot.Worker/StevesBot.Worker.csproj ./ -COPY ./Directory.Build.props ./ -RUN dotnet restore -COPY ./StevesBot.Worker/. ./ - -FROM base AS publish-stage -RUN dotnet publish -c Release -o dist - -FROM mcr.microsoft.com/dotnet/aspnet:9.0 -WORKDIR /app -COPY --from=publish-stage /app/dist ./ -ENTRYPOINT ["dotnet", "StevesBot.Worker.dll"] \ No newline at end of file diff --git a/src/StevesBot.Webhook.Dockerfile b/src/StevesBot.Webhook.Dockerfile new file mode 100644 index 0000000..f9909e1 --- /dev/null +++ b/src/StevesBot.Webhook.Dockerfile @@ -0,0 +1,35 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS base +WORKDIR /app + +# solution level +COPY *.sln ./ +COPY Directory.Build.props ./ + +# src level +COPY src/StevesBot.Library/*.csproj src/StevesBot.Library/ +COPY src/StevesBot.Worker/*.csproj src/StevesBot.Worker/ +COPY src/StevesBot.Webhook/*.csproj src/StevesBot.Webhook/ +COPY src/Directory.Build.props src/ +COPY src/Directory.Packages.props src/ + +# test level +COPY tests/StevesBot.Library.Tests/*.csproj tests/StevesBot.Library.Tests/ +COPY tests/StevesBot.Worker.Tests/*.csproj tests/StevesBot.Worker.Tests/ +COPY tests/StevesBot.Webhook.Tests/*.csproj tests/StevesBot.Webhook.Tests/ +COPY tests/Directory.Build.props tests/ +COPY tests/Directory.Packages.props tests/ + +COPY ./src/StevesBot.Worker/StevesBot.Worker.csproj ./ +COPY ./Directory.Build.props ./ + +RUN dotnet restore StevesBot.sln + +COPY . . + +FROM base AS publish-stage +RUN dotnet publish -c Release -o dist src/StevesBot.Webhook/StevesBot.Webhook.csproj + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 +WORKDIR /app +COPY --from=publish-stage /app/dist ./ +ENTRYPOINT ["dotnet", "StevesBot.Webhook.dll"] diff --git a/src/StevesBot.Worker.Dockerfile b/src/StevesBot.Worker.Dockerfile new file mode 100644 index 0000000..0a199f3 --- /dev/null +++ b/src/StevesBot.Worker.Dockerfile @@ -0,0 +1,35 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS base +WORKDIR /app + +# solution level +COPY *.sln ./ +COPY Directory.Build.props ./ + +# src level +COPY src/StevesBot.Library/*.csproj src/StevesBot.Library/ +COPY src/StevesBot.Worker/*.csproj src/StevesBot.Worker/ +COPY src/StevesBot.Webhook/*.csproj src/StevesBot.Webhook/ +COPY src/Directory.Build.props src/ +COPY src/Directory.Packages.props src/ + +# test level +COPY tests/StevesBot.Library.Tests/*.csproj tests/StevesBot.Library.Tests/ +COPY tests/StevesBot.Worker.Tests/*.csproj tests/StevesBot.Worker.Tests/ +COPY tests/StevesBot.Webhook.Tests/*.csproj tests/StevesBot.Webhook.Tests/ +COPY tests/Directory.Build.props tests/ +COPY tests/Directory.Packages.props tests/ + +COPY ./src/StevesBot.Worker/StevesBot.Worker.csproj ./ +COPY ./Directory.Build.props ./ + +RUN dotnet restore StevesBot.sln + +COPY . . + +FROM base AS publish-stage +RUN dotnet publish -c Release -o dist src/StevesBot.Worker/StevesBot.Worker.csproj + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 +WORKDIR /app +COPY --from=publish-stage /app/dist ./ +ENTRYPOINT ["dotnet", "StevesBot.Worker.dll"] diff --git a/src/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj b/src/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj deleted file mode 100644 index d225104..0000000 --- a/src/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj +++ /dev/null @@ -1,46 +0,0 @@ - - - - false - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - true - ./TestResults/Coverage/ - cobertura - [StevesBot.Worker]* - **/Program.cs,**/Worker.cs - - - - - - - - - - - - - - - - diff --git a/src/StevesBot.Worker/Discord/Rest/Requests/CreateMessageRequest.cs b/src/StevesBot.Worker/Discord/Rest/Requests/CreateMessageRequest.cs deleted file mode 100644 index cf96e12..0000000 --- a/src/StevesBot.Worker/Discord/Rest/Requests/CreateMessageRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace StevesBot.Worker.Discord.Rest.Requests; - -internal sealed record CreateMessageRequest( - [property: JsonPropertyName("content")] string Content, - [property: JsonPropertyName("message_reference")] DiscordMessageReference? MessageReference -); \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Rest/Responses/GatewayResponse.cs b/src/StevesBot.Worker/Discord/Rest/Responses/GatewayResponse.cs deleted file mode 100644 index e8f7a69..0000000 --- a/src/StevesBot.Worker/Discord/Rest/Responses/GatewayResponse.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace StevesBot.Worker.Discord.Rest.Responses; - -internal sealed record GatewayResponse( - [property: JsonPropertyName("url")] string Url -); \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Shared/DiscordMessageReferenceTypes.cs b/src/StevesBot.Worker/Discord/Shared/DiscordMessageReferenceTypes.cs deleted file mode 100644 index 8d43052..0000000 --- a/src/StevesBot.Worker/Discord/Shared/DiscordMessageReferenceTypes.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace StevesBot.Worker.Discord.Shared; - -internal static class DiscordMessageReferenceTypes -{ - public const int Default = 0; - public const int Forward = 1; -} \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Shared/DiscordUser.cs b/src/StevesBot.Worker/Discord/Shared/DiscordUser.cs deleted file mode 100644 index eba9acf..0000000 --- a/src/StevesBot.Worker/Discord/Shared/DiscordUser.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace StevesBot.Worker.Discord.Shared; - -internal sealed record DiscordUser -{ - [JsonPropertyName("id")] - public string Id { get; init; } = string.Empty; -} \ No newline at end of file diff --git a/src/StevesBot.Worker/StevesBot.Worker.csproj b/src/StevesBot.Worker/StevesBot.Worker.csproj deleted file mode 100644 index 581c7c2..0000000 --- a/src/StevesBot.Worker/StevesBot.Worker.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - dotnet-StevesBot.Worker-0b7a94ae-5cbd-406d-8422-1c791715ef7d - - - - - - - - - - - - - - - - - - diff --git a/src/StevesBot.Worker/Telemetry/Instrumentation.cs b/src/StevesBot.Worker/Telemetry/Instrumentation.cs deleted file mode 100644 index 9843dc0..0000000 --- a/src/StevesBot.Worker/Telemetry/Instrumentation.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace StevesBot.Worker.Telemetry; - -internal sealed class Instrumentation : IDisposable -{ - public const string SourceName = "StevesBot.Worker"; - public const string SourceVersion = "0.0.0"; - public readonly ActivitySource Source = new(SourceName, SourceVersion); - - public void Dispose() - { - Source.Dispose(); - } -} \ No newline at end of file diff --git a/src/StevesBot.sln b/src/StevesBot.sln index c4dc140..5d88949 100644 --- a/src/StevesBot.sln +++ b/src/StevesBot.sln @@ -3,9 +3,21 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StevesBot.Worker", "StevesBot.Worker\StevesBot.Worker.csproj", "{C45EE62D-8222-46D7-B4FF-22B155B17561}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StevesBot.Worker.Tests", "StevesBot.Worker.Tests\StevesBot.Worker.Tests.csproj", "{FFB90DB8-1B9A-47E3-8252-746F1445CA02}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StevesBot.Worker", "src\StevesBot.Worker\StevesBot.Worker.csproj", "{51B85887-7083-47A0-B83F-4E1561B14AA9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StevesBot.Webhook", "src\StevesBot.Webhook\StevesBot.Webhook.csproj", "{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StevesBot.Worker.Tests", "tests\StevesBot.Worker.Tests\StevesBot.Worker.Tests.csproj", "{388EAD53-CCA4-4532-8DEC-5E9177776117}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StevesBot.Webhook.Tests", "tests\StevesBot.Webhook.Tests\StevesBot.Webhook.Tests.csproj", "{3875E45D-8400-46D9-89CE-485BADED0736}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StevesBot.Library", "src\StevesBot.Library\StevesBot.Library.csproj", "{07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StevesBot.Library.Tests", "tests\StevesBot.Library.Tests\StevesBot.Library.Tests.csproj", "{F67829A2-3600-4DD9-B324-18DDF4A0809B}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -17,32 +29,88 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|x64.ActiveCfg = Debug|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|x64.Build.0 = Debug|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|x86.ActiveCfg = Debug|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|x86.Build.0 = Debug|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|Any CPU.Build.0 = Release|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|x64.ActiveCfg = Release|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|x64.Build.0 = Release|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|x86.ActiveCfg = Release|Any CPU - {C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|x86.Build.0 = Release|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|x64.ActiveCfg = Debug|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|x64.Build.0 = Debug|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|x86.ActiveCfg = Debug|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|x86.Build.0 = Debug|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|Any CPU.Build.0 = Release|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|x64.ActiveCfg = Release|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|x64.Build.0 = Release|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|x86.ActiveCfg = Release|Any CPU - {FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|x86.Build.0 = Release|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|x64.ActiveCfg = Debug|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|x64.Build.0 = Debug|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|x86.ActiveCfg = Debug|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|x86.Build.0 = Debug|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|Any CPU.Build.0 = Release|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|x64.ActiveCfg = Release|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|x64.Build.0 = Release|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|x86.ActiveCfg = Release|Any CPU + {51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|x86.Build.0 = Release|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|x64.Build.0 = Debug|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|x86.Build.0 = Debug|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|Any CPU.Build.0 = Release|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|x64.ActiveCfg = Release|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|x64.Build.0 = Release|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|x86.ActiveCfg = Release|Any CPU + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|x86.Build.0 = Release|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Debug|Any CPU.Build.0 = Debug|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Debug|x64.ActiveCfg = Debug|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Debug|x64.Build.0 = Debug|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Debug|x86.ActiveCfg = Debug|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Debug|x86.Build.0 = Debug|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Release|Any CPU.ActiveCfg = Release|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Release|Any CPU.Build.0 = Release|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Release|x64.ActiveCfg = Release|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Release|x64.Build.0 = Release|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Release|x86.ActiveCfg = Release|Any CPU + {388EAD53-CCA4-4532-8DEC-5E9177776117}.Release|x86.Build.0 = Release|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Debug|x64.ActiveCfg = Debug|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Debug|x64.Build.0 = Debug|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Debug|x86.ActiveCfg = Debug|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Debug|x86.Build.0 = Debug|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Release|Any CPU.Build.0 = Release|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Release|x64.ActiveCfg = Release|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Release|x64.Build.0 = Release|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Release|x86.ActiveCfg = Release|Any CPU + {3875E45D-8400-46D9-89CE-485BADED0736}.Release|x86.Build.0 = Release|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Debug|x64.ActiveCfg = Debug|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Debug|x64.Build.0 = Debug|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Debug|x86.ActiveCfg = Debug|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Debug|x86.Build.0 = Debug|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Release|Any CPU.Build.0 = Release|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Release|x64.ActiveCfg = Release|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Release|x64.Build.0 = Release|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Release|x86.ActiveCfg = Release|Any CPU + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81}.Release|x86.Build.0 = Release|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Debug|x64.ActiveCfg = Debug|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Debug|x64.Build.0 = Debug|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Debug|x86.ActiveCfg = Debug|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Debug|x86.Build.0 = Debug|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Release|Any CPU.Build.0 = Release|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Release|x64.ActiveCfg = Release|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Release|x64.Build.0 = Release|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Release|x86.ActiveCfg = Release|Any CPU + {F67829A2-3600-4DD9-B324-18DDF4A0809B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {51B85887-7083-47A0-B83F-4E1561B14AA9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {7C243CA8-55A0-4D73-9FF6-F5E801B7146D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {388EAD53-CCA4-4532-8DEC-5E9177776117} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {3875E45D-8400-46D9-89CE-485BADED0736} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {07B3FA35-02B7-46E6-BEDC-D64C5D0FBE81} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {F67829A2-3600-4DD9-B324-18DDF4A0809B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection EndGlobal diff --git a/src/compose.yml b/src/compose.yml new file mode 100644 index 0000000..c87c1c3 --- /dev/null +++ b/src/compose.yml @@ -0,0 +1,40 @@ +name: StevesBot +services: + webhook: + container_name: steves-bot-webhook + image: stevanfreeborn/steves-bot:${WEBHOOK_VERSION} + ports: + - '34827:8080' + restart: always + environment: + - Logging__LogLevel__Default=Debug + - Logging__LogLevel__Microsoft=Information + - Logging__LogLevel__Microsoft.Hosting.Lifetime=Information + - YouTubeClientOptions__BaseUrl=${YOUTUBE_BASE_URL} + - YouTubeClientOptions__ApiKey=${YOUTUBE_API_KEY} + - SubscriptionOptions__CallbackUrl=${SUBSCRIPTION_CALLBACK_URL} + - SubscriptionOptions__TopicUrl=${SUBSCRIPTION_TOPIC_URL} + - PubSubClientOptions__BaseUrl=${PUBSUB_BASE_URL} + - DiscordClientOptions__ApiUrl=${DISCORD_API_URL} + - DiscordClientOptions__AppToken=${DISCORD_APP_TOKEN} + - DiscordClientOptions__Intents=${DISCORD_INTENTS} + - DiscordNotificationOptions__ChannelId=${DISCORD_NOTIFICATION_CHANNEL_ID} + - DiscordNotificationOptions__MessageFormat="${DISCORD_NOTIFICATION_MESSAGE_FORMAT}" + - SeqOptions__ServerUrl=${SEQ_SERVER_URL} + - SeqOptions__ApiKeyHeader=${SEQ_API_KEY_HEADER} + - SeqOptions__ApiKey=${SEQ_WEBHOOK_API_KEY} + - DOTNET_ENVIRONMENT=Production + worker: + container_name: steves-bot-worker + image: stevanfreeborn/steves-bot:${WORKER_VERSION} + environment: + - Logging__LogLevel__Default=Debug + - Logging__LogLevel__Microsoft=Information + - Logging__LogLevel__Microsoft.Hosting.Lifetime=Information + - DiscordClientOptions__ApiUrl=${DISCORD_API_URL} + - DiscordClientOptions__AppToken=${DISCORD_APP_TOKEN} + - DiscordClientOptions__Intents=${DISCORD_INTENTS} + - SeqOptions__ServerUrl=${SEQ_SERVER_URL} + - SeqOptions__ApiKeyHeader=${SEQ_API_KEY_HEADER} + - SeqOptions__ApiKey=${SEQ_WORKER_API_KEY} + - DOTNET_ENVIRONMENT=Production diff --git a/src/StevesBot.Worker/.editorconfig b/src/src/.editorconfig similarity index 100% rename from src/StevesBot.Worker/.editorconfig rename to src/src/.editorconfig diff --git a/src/src/Directory.Build.props b/src/src/Directory.Build.props new file mode 100644 index 0000000..634b868 --- /dev/null +++ b/src/src/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/src/Directory.Packages.props b/src/src/Directory.Packages.props new file mode 100644 index 0000000..e98ac39 --- /dev/null +++ b/src/src/Directory.Packages.props @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + + diff --git a/src/src/StevesBot.Library/.editorconfig b/src/src/StevesBot.Library/.editorconfig new file mode 100644 index 0000000..738ea45 --- /dev/null +++ b/src/src/StevesBot.Library/.editorconfig @@ -0,0 +1,3 @@ +[*.cs] +dotnet_diagnostic.CA1056.severity = none +dotnet_diagnostic.CA1054.severity = none diff --git a/src/StevesBot.Worker/Discord/Shared/DiscordClientOptions.cs b/src/src/StevesBot.Library/Discord/Common/DiscordClientOptions.cs similarity index 63% rename from src/StevesBot.Worker/Discord/Shared/DiscordClientOptions.cs rename to src/src/StevesBot.Library/Discord/Common/DiscordClientOptions.cs index 38547a0..ddfbca3 100644 --- a/src/StevesBot.Worker/Discord/Shared/DiscordClientOptions.cs +++ b/src/src/StevesBot.Library/Discord/Common/DiscordClientOptions.cs @@ -1,6 +1,6 @@ -namespace StevesBot.Worker.Discord.Shared; +namespace StevesBot.Library.Discord.Common; -internal sealed class DiscordClientOptions +public sealed class DiscordClientOptions { public string ApiUrl { get; init; } = string.Empty; public string AppToken { get; init; } = string.Empty; diff --git a/src/StevesBot.Worker/Discord/Shared/DiscordMessage.cs b/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs similarity index 78% rename from src/StevesBot.Worker/Discord/Shared/DiscordMessage.cs rename to src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs index e29088f..bf286ef 100644 --- a/src/StevesBot.Worker/Discord/Shared/DiscordMessage.cs +++ b/src/src/StevesBot.Library/Discord/Common/DiscordMessage.cs @@ -1,6 +1,8 @@ -namespace StevesBot.Worker.Discord.Shared; +using System.Text.Json.Serialization; -internal sealed record DiscordMessage +namespace StevesBot.Library.Discord.Common; + +public sealed record DiscordMessage { [JsonPropertyName("id")] public string Id { get; init; } = string.Empty; diff --git a/src/StevesBot.Worker/Discord/Shared/DiscordMessageReference.cs b/src/src/StevesBot.Library/Discord/Common/DiscordMessageReference.cs similarity index 70% rename from src/StevesBot.Worker/Discord/Shared/DiscordMessageReference.cs rename to src/src/StevesBot.Library/Discord/Common/DiscordMessageReference.cs index a950137..a2c9416 100644 --- a/src/StevesBot.Worker/Discord/Shared/DiscordMessageReference.cs +++ b/src/src/StevesBot.Library/Discord/Common/DiscordMessageReference.cs @@ -1,6 +1,8 @@ -namespace StevesBot.Worker.Discord.Shared; +using System.Text.Json.Serialization; -internal sealed record DiscordMessageReference( +namespace StevesBot.Library.Discord.Common; + +public sealed record DiscordMessageReference( [property: JsonPropertyName("type")] int Type, [property: JsonPropertyName("message_id")] string MessageId, [property: JsonPropertyName("channel_id")] string ChannelId, diff --git a/src/src/StevesBot.Library/Discord/Common/DiscordMessageReferenceTypes.cs b/src/src/StevesBot.Library/Discord/Common/DiscordMessageReferenceTypes.cs new file mode 100644 index 0000000..7c66727 --- /dev/null +++ b/src/src/StevesBot.Library/Discord/Common/DiscordMessageReferenceTypes.cs @@ -0,0 +1,7 @@ +namespace StevesBot.Library.Discord.Common; + +public static class DiscordMessageReferenceTypes +{ + public const int Default = 0; + public const int Forward = 1; +} \ No newline at end of file diff --git a/src/src/StevesBot.Library/Discord/Common/DiscordUser.cs b/src/src/StevesBot.Library/Discord/Common/DiscordUser.cs new file mode 100644 index 0000000..13a8393 --- /dev/null +++ b/src/src/StevesBot.Library/Discord/Common/DiscordUser.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace StevesBot.Library.Discord.Common; + +public sealed record DiscordUser +{ + [JsonPropertyName("id")] + public string Id { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Rest/DiscordRestClient.cs b/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs similarity index 87% rename from src/StevesBot.Worker/Discord/Rest/DiscordRestClient.cs rename to src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs index 45b2ecd..6995683 100644 --- a/src/StevesBot.Worker/Discord/Rest/DiscordRestClient.cs +++ b/src/src/StevesBot.Library/Discord/Rest/DiscordRestClient.cs @@ -1,6 +1,14 @@ -namespace StevesBot.Worker.Discord.Rest; +using System.Net.Http.Json; -internal sealed class DiscordRestClient : IDiscordRestClient +using Microsoft.Extensions.Logging; + +using StevesBot.Library.Discord.Common; +using StevesBot.Library.Discord.Rest.Requests; +using StevesBot.Library.Discord.Rest.Responses; + +namespace StevesBot.Library.Discord.Rest; + +public sealed class DiscordRestClient : IDiscordRestClient { private readonly ILogger _logger; private readonly HttpClient _httpClient; diff --git a/src/StevesBot.Worker/Discord/Rest/DiscordRestClientException.cs b/src/src/StevesBot.Library/Discord/Rest/DiscordRestClientException.cs similarity index 70% rename from src/StevesBot.Worker/Discord/Rest/DiscordRestClientException.cs rename to src/src/StevesBot.Library/Discord/Rest/DiscordRestClientException.cs index 9e8e4b5..6f27517 100644 --- a/src/StevesBot.Worker/Discord/Rest/DiscordRestClientException.cs +++ b/src/src/StevesBot.Library/Discord/Rest/DiscordRestClientException.cs @@ -1,6 +1,6 @@ -namespace StevesBot.Worker.Discord.Rest; +namespace StevesBot.Library.Discord.Rest; -internal sealed class DiscordRestClientException : Exception +public sealed class DiscordRestClientException : Exception { public DiscordRestClientException() { diff --git a/src/StevesBot.Worker/Discord/Rest/IDiscordRestClient.cs b/src/src/StevesBot.Library/Discord/Rest/IDiscordRestClient.cs similarity index 57% rename from src/StevesBot.Worker/Discord/Rest/IDiscordRestClient.cs rename to src/src/StevesBot.Library/Discord/Rest/IDiscordRestClient.cs index a1f1f0b..8368ff5 100644 --- a/src/StevesBot.Worker/Discord/Rest/IDiscordRestClient.cs +++ b/src/src/StevesBot.Library/Discord/Rest/IDiscordRestClient.cs @@ -1,6 +1,9 @@ -namespace StevesBot.Worker.Discord.Rest; +using StevesBot.Library.Discord.Common; +using StevesBot.Library.Discord.Rest.Requests; -internal interface IDiscordRestClient +namespace StevesBot.Library.Discord.Rest; + +public interface IDiscordRestClient { Task GetGatewayUrlAsync(CancellationToken cancellationToken = default); Task CreateMessageAsync(string channelId, CreateMessageRequest request, CancellationToken cancellationToken = default); diff --git a/src/src/StevesBot.Library/Discord/Rest/Requests/CreateMessageRequest.cs b/src/src/StevesBot.Library/Discord/Rest/Requests/CreateMessageRequest.cs new file mode 100644 index 0000000..abe91a6 --- /dev/null +++ b/src/src/StevesBot.Library/Discord/Rest/Requests/CreateMessageRequest.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +using StevesBot.Library.Discord.Common; + +namespace StevesBot.Library.Discord.Rest.Requests; + +public sealed record CreateMessageRequest( + [property: JsonPropertyName("content")] string Content, + [property: JsonPropertyName("message_reference")] DiscordMessageReference? MessageReference +); \ No newline at end of file diff --git a/src/src/StevesBot.Library/Discord/Rest/Responses/GatewayResponse.cs b/src/src/StevesBot.Library/Discord/Rest/Responses/GatewayResponse.cs new file mode 100644 index 0000000..cc810d3 --- /dev/null +++ b/src/src/StevesBot.Library/Discord/Rest/Responses/GatewayResponse.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace StevesBot.Library.Discord.Rest.Responses; + +public sealed record GatewayResponse( + [property: JsonPropertyName("url")] string Url +); \ No newline at end of file diff --git a/src/src/StevesBot.Library/Discord/ServicesExtensions.cs b/src/src/StevesBot.Library/Discord/ServicesExtensions.cs new file mode 100644 index 0000000..71a9183 --- /dev/null +++ b/src/src/StevesBot.Library/Discord/ServicesExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.DependencyInjection; + +using StevesBot.Library.Discord.Common; +using StevesBot.Library.Discord.Rest; +using StevesBot.Library.Telemetry; + +namespace StevesBot.Library.Discord; + +public static class ServicesExtensions +{ + public static IServiceCollection AddDiscordRestClient(this IServiceCollection services) + { + services + .AddHttpClient(static (sp, c) => + { + var discordOptions = sp.GetRequiredService(); + c.BaseAddress = new Uri(discordOptions.ApiUrl); + c.DefaultRequestHeaders.Authorization = new("Bot", discordOptions.AppToken); + + var instrumentation = sp.GetService(); + var version = instrumentation?.SourceVersion ?? "0.0.0"; + + var userAgentString = $"DiscordBot (https://github.com/StevanFreeborn/steves-bot, {version})"; + c.DefaultRequestHeaders.Add("User-Agent", userAgentString); + }) + .AddStandardResilienceHandler(); + + return services; + } + +} \ No newline at end of file diff --git a/src/src/StevesBot.Library/StevesBot.Library.csproj b/src/src/StevesBot.Library/StevesBot.Library.csproj new file mode 100644 index 0000000..fc1f3b1 --- /dev/null +++ b/src/src/StevesBot.Library/StevesBot.Library.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/StevesBot.Worker/Telemetry/Extensions.cs b/src/src/StevesBot.Library/Telemetry/HostExtensions.cs similarity index 54% rename from src/StevesBot.Worker/Telemetry/Extensions.cs rename to src/src/StevesBot.Library/Telemetry/HostExtensions.cs index 0e264a1..1afb0d1 100644 --- a/src/StevesBot.Worker/Telemetry/Extensions.cs +++ b/src/src/StevesBot.Library/Telemetry/HostExtensions.cs @@ -1,10 +1,20 @@ -namespace StevesBot.Worker.Telemetry; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; -internal static class Extensions +using OpenTelemetry.Exporter; +using OpenTelemetry.Logs; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace StevesBot.Library.Telemetry; + +public static class HostExtensions { - public static HostApplicationBuilder AddTelemetry(this HostApplicationBuilder builder) + public static IHostApplicationBuilder AddTelemetry(this IHostApplicationBuilder builder, Func instrumentationFunc) { - builder.Services.AddSingleton(); + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(instrumentationFunc); var seq = new SeqOptions(); builder.Configuration.GetSection(nameof(SeqOptions)).Bind(seq); @@ -14,14 +24,20 @@ internal static class Extensions return builder; } + builder.Services.AddSingleton(instrumentationFunc()); + + var instrumentation = builder.Services + .BuildServiceProvider() + .GetRequiredService(); + builder.Services.AddOpenTelemetry() .ConfigureResource(resource => { - resource.AddService(Instrumentation.SourceName, Instrumentation.SourceVersion); + resource.AddService(instrumentation.SourceName, instrumentation.SourceVersion); resource.AddAttributes(new Dictionary { - ["service.name"] = Instrumentation.SourceName, - ["service.version"] = Instrumentation.SourceVersion, + ["service.name"] = instrumentation.SourceName, + ["service.version"] = instrumentation.SourceVersion, ["service.instance.id"] = Environment.MachineName, ["service.namespace"] = "stevesbot", ["service.environment"] = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "production", @@ -38,7 +54,7 @@ internal static class Extensions }) .WithTracing(tb => { - tb.AddSource(Instrumentation.SourceName); + tb.AddSource(instrumentation.SourceName); tb.AddAspNetCoreInstrumentation(); tb.AddHttpClientInstrumentation(); tb.AddOtlpExporter(o => diff --git a/src/src/StevesBot.Library/Telemetry/IInstrumentation.cs b/src/src/StevesBot.Library/Telemetry/IInstrumentation.cs new file mode 100644 index 0000000..1fd6eb7 --- /dev/null +++ b/src/src/StevesBot.Library/Telemetry/IInstrumentation.cs @@ -0,0 +1,10 @@ +using System.Diagnostics; + +namespace StevesBot.Library.Telemetry; + +public interface IInstrumentation : IDisposable +{ + string SourceName { get; } + string SourceVersion { get; } + ActivitySource Source { get; } +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Telemetry/SeqOptions.cs b/src/src/StevesBot.Library/Telemetry/SeqOptions.cs similarity index 93% rename from src/StevesBot.Worker/Telemetry/SeqOptions.cs rename to src/src/StevesBot.Library/Telemetry/SeqOptions.cs index cf5b801..8c2ea69 100644 --- a/src/StevesBot.Worker/Telemetry/SeqOptions.cs +++ b/src/src/StevesBot.Library/Telemetry/SeqOptions.cs @@ -1,4 +1,4 @@ -namespace StevesBot.Worker.Telemetry; +namespace StevesBot.Library.Telemetry; internal sealed class SeqOptions { diff --git a/src/src/StevesBot.Webhook/Program.cs b/src/src/StevesBot.Webhook/Program.cs new file mode 100644 index 0000000..18ef1f0 --- /dev/null +++ b/src/src/StevesBot.Webhook/Program.cs @@ -0,0 +1,73 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.AddTelemetry(static () => new StevesBotWebhookInstrumentation()); + +builder.Services + .AddOptionsWithValidateOnStart() + .BindConfiguration(nameof(SubscriptionOptions)) + .ValidateDataAnnotations(); + +builder.Services + .AddOptionsWithValidateOnStart() + .BindConfiguration(nameof(YouTubeClientOptions)) + .ValidateDataAnnotations(); + +builder.Services + .AddOptionsWithValidateOnStart() + .BindConfiguration(nameof(PubSubClientOptions)) + .ValidateDataAnnotations(); + +builder.Services + .AddHttpClient( + static (sp, c) => + { + var options = sp.GetRequiredService>().Value; + c.BaseAddress = new(options.BaseUrl); + } + ) + .AddStandardResilienceHandler(); + +builder.Services + .AddHttpClient( + static (sp, c) => + { + var options = sp.GetRequiredService>().Value; + c.BaseAddress = new(options.BaseUrl); + } + ) + .AddStandardResilienceHandler(); + +builder.Services.AddOpenApi(); + +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton>(); +builder.Services.AddHostedService(); + +builder.Services.AddSingleton(); + +builder.Services.AddOptionsWithValidateOnStart() + .BindConfiguration(nameof(DiscordNotificationOptions)) + .ValidateDataAnnotations(); + +builder.Services.AddOptions() + .BindConfiguration(nameof(DiscordClientOptions)); + +builder.Services.AddSingleton( + static sp => sp.GetRequiredService>().Value +); + +builder.Services.AddDiscordRestClient(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +const string ytCallback = "yt-callback"; + +app.MapGet(ytCallback, VerifySubscriptionHandler.Handle); +app.MapPost(ytCallback, NotificationHandler.HandleAsync); + +app.Run(); \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/Properties/launchSettings.json b/src/src/StevesBot.Webhook/Properties/launchSettings.json new file mode 100644 index 0000000..cddbc7b --- /dev/null +++ b/src/src/StevesBot.Webhook/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7231;http://localhost:5228", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5228", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + } +} diff --git a/src/src/StevesBot.Webhook/StevesBot.Webhook.csproj b/src/src/StevesBot.Webhook/StevesBot.Webhook.csproj new file mode 100644 index 0000000..79c3094 --- /dev/null +++ b/src/src/StevesBot.Webhook/StevesBot.Webhook.csproj @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/src/StevesBot.Webhook/Telemetry/StevesBotWebhookInstrumentation.cs b/src/src/StevesBot.Webhook/Telemetry/StevesBotWebhookInstrumentation.cs new file mode 100644 index 0000000..eb7a13a --- /dev/null +++ b/src/src/StevesBot.Webhook/Telemetry/StevesBotWebhookInstrumentation.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; + +namespace StevesBot.Webhook.Telemetry; + +internal sealed class StevesBotWebhookInstrumentation : IInstrumentation +{ + private const string SourceNameValue = "StevesBot.Webhook"; + private const string SourceVersionValue = "0.0.0"; + + public string SourceName { get; } = SourceNameValue; + public string SourceVersion { get; } = SourceVersionValue; + public ActivitySource Source { get; } = new ActivitySource(SourceNameValue, SourceVersionValue); + + public void Dispose() + { + Source.Dispose(); + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/Usings.cs b/src/src/StevesBot.Webhook/Usings.cs new file mode 100644 index 0000000..e15daf7 --- /dev/null +++ b/src/src/StevesBot.Webhook/Usings.cs @@ -0,0 +1,20 @@ +global using System.Collections.Concurrent; +global using System.ComponentModel.DataAnnotations; +global using System.Globalization; +global using System.Text.Json.Serialization; +global using System.Text.RegularExpressions; + +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.WebUtilities; +global using Microsoft.Extensions.Options; + +global using StevesBot.Library.Discord; +global using StevesBot.Library.Discord.Common; +global using StevesBot.Library.Discord.Rest; +global using StevesBot.Library.Discord.Rest.Requests; +global using StevesBot.Library.Telemetry; +global using StevesBot.Webhook.Telemetry; +global using StevesBot.Webhook.YouTube; +global using StevesBot.Webhook.YouTube.Data; +global using StevesBot.Webhook.YouTube.Handlers; +global using StevesBot.Webhook.YouTube.Tasks; \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Data/IYouTubeDataApiClient.cs b/src/src/StevesBot.Webhook/YouTube/Data/IYouTubeDataApiClient.cs new file mode 100644 index 0000000..32f1df1 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Data/IYouTubeDataApiClient.cs @@ -0,0 +1,10 @@ +namespace StevesBot.Webhook.YouTube.Data; + +internal interface IYouTubeDataApiClient +{ + Task GetVideoByIdAsync( + string videoId, + string[]? part = null, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Data/YouTubeDataApiClient.cs b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeDataApiClient.cs new file mode 100644 index 0000000..78ee387 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeDataApiClient.cs @@ -0,0 +1,52 @@ +namespace StevesBot.Webhook.YouTube.Data; + +internal sealed class YouTubeDataApiClient( + HttpClient httpClient, + ILogger logger, + IOptions options +) : IYouTubeDataApiClient +{ + private const string VideosEndpoint = "videos"; + private readonly HttpClient _httpClient = httpClient; + private readonly ILogger _logger = logger; + private readonly YouTubeClientOptions _options = options.Value; + + public async Task GetVideoByIdAsync( + string videoId, + string[]? part = null, + CancellationToken cancellationToken = default + ) + { + var queryParams = new Dictionary + { + ["id"] = videoId, + ["key"] = _options.ApiKey, + }; + + if (part is not null && part.Length > 0) + { + queryParams["part"] = string.Join(',', part); + } + + var requestEndpoint = QueryHelpers.AddQueryString(VideosEndpoint, queryParams); + var requestUri = new Uri(requestEndpoint, UriKind.Relative); + var response = await _httpClient.GetAsync(requestUri, cancellationToken); + + if (response.IsSuccessStatusCode is false) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + _logger.LogDebug( + "Failed to get video by ID {VideoId} from YouTube Data API. Status code: {StatusCode}, Content: {Content}", + videoId, + response.StatusCode, + content + ); + + return null; + } + + var responseContent = await response.Content.ReadFromJsonAsync(cancellationToken); + return responseContent?.Items.FirstOrDefault(i => i.Id == videoId); + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Data/YouTubeLiveStreamingDetails.cs b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeLiveStreamingDetails.cs new file mode 100644 index 0000000..b4781a1 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeLiveStreamingDetails.cs @@ -0,0 +1,22 @@ +namespace StevesBot.Webhook.YouTube.Data; + +internal sealed record YouTubeLiveStreamingDetails +{ + [JsonPropertyName("actualStartTime")] + public DateTimeOffset? ActualStartTime { get; init; } + + [JsonPropertyName("actualEndTime")] + public DateTimeOffset? ActualEndTime { get; init; } + + [JsonPropertyName("scheduledStartTime")] + public DateTimeOffset? ScheduledStartTime { get; init; } + + [JsonPropertyName("scheduledEndTime")] + public DateTimeOffset? ScheduledEndTime { get; init; } + + [JsonPropertyName("concurrentViewers")] + public ulong? ConcurrentViewers { get; init; } + + [JsonPropertyName("activeLiveChatId")] + public string? ActiveLiveChatId { get; init; } +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Data/YouTubePageInfo.cs b/src/src/StevesBot.Webhook/YouTube/Data/YouTubePageInfo.cs new file mode 100644 index 0000000..717ee98 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Data/YouTubePageInfo.cs @@ -0,0 +1,10 @@ +namespace StevesBot.Webhook.YouTube.Data; + +internal sealed record YouTubePageInfo +{ + [JsonPropertyName("totalResults")] + public int TotalResults { get; init; } + + [JsonPropertyName("resultsPerPage")] + public int ResultsPerPage { get; init; } +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Data/YouTubeSnippet.cs b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeSnippet.cs new file mode 100644 index 0000000..7b0105f --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeSnippet.cs @@ -0,0 +1,7 @@ +namespace StevesBot.Webhook.YouTube.Data; + +internal sealed record YouTubeSnippet +{ + [JsonPropertyName("liveBroadcastContent")] + public string LiveBroadcastContent { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Data/YouTubeVideo.cs b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeVideo.cs new file mode 100644 index 0000000..6f68867 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeVideo.cs @@ -0,0 +1,15 @@ +namespace StevesBot.Webhook.YouTube.Data; + +internal sealed record YouTubeVideo +{ + [JsonPropertyName("id")] + public string Id { get; init; } = string.Empty; + + [JsonPropertyName("liveStreamingDetails")] + public YouTubeLiveStreamingDetails? LiveStreamingDetails { get; init; } + + [JsonPropertyName("snippet")] + public YouTubeSnippet Snippet { get; init; } = new(); + + public bool IsLiveStream => LiveStreamingDetails is not null && Snippet.LiveBroadcastContent is "live"; +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Data/YouTubeVideoListResponse.cs b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeVideoListResponse.cs new file mode 100644 index 0000000..2c259f7 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Data/YouTubeVideoListResponse.cs @@ -0,0 +1,10 @@ +namespace StevesBot.Webhook.YouTube.Data; + +internal sealed record YouTubeVideoListResponse +{ + [JsonPropertyName("items")] + public YouTubeVideo[] Items { get; init; } = []; + + [JsonPropertyName("pageInfo")] + public YouTubePageInfo PageInfo { get; init; } = new YouTubePageInfo(); +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/DiscordNotificationOptions.cs b/src/src/StevesBot.Webhook/YouTube/DiscordNotificationOptions.cs new file mode 100644 index 0000000..b373411 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/DiscordNotificationOptions.cs @@ -0,0 +1,10 @@ +namespace StevesBot.Webhook.YouTube; + +internal sealed record DiscordNotificationOptions +{ + [Required] + public string ChannelId { get; init; } = string.Empty; + + [Required] + public string MessageFormat { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Handlers/NotificationHandler.cs b/src/src/StevesBot.Webhook/YouTube/Handlers/NotificationHandler.cs new file mode 100644 index 0000000..b8b3680 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Handlers/NotificationHandler.cs @@ -0,0 +1,80 @@ +namespace StevesBot.Webhook.YouTube.Handlers; + +internal static class NotificationHandler +{ + public static async Task HandleAsync( + HttpContext context, + [FromServices] ILogger logger, + [FromServices] IYouTubeDataApiClient youTubeDataApiClient, + [FromServices] IDiscordRestClient discordRestClient, + [FromServices] IOptionsMonitor discordNotificationOptions, + [FromServices] ILastPostedStreamStore lastPostedStreamStore, + CancellationToken cancellationToken = default + ) + { + using StreamReader stream = new(context.Request.Body); + var body = await stream.ReadToEndAsync(cancellationToken); + + var videoIdRegex = VideoIdRegex.Regex(); + var match = videoIdRegex.Match(body); + + if (match.Success is false) + { + logger.LogWarning("No video ID found in the request body."); + return Results.BadRequest("No video ID found in the request body."); + } + + var videoId = match.Groups[1].Value; + var parts = new string[] { "liveStreamingDetails", "snippet" }; + var video = await youTubeDataApiClient.GetVideoByIdAsync(videoId, parts, cancellationToken); + + if (video is null) + { + logger.LogWarning("Video with ID {VideoId} not found.", videoId); + return Results.NotFound(); + } + + if (video.IsLiveStream is false) + { + logger.LogInformation("Video ID {VideoId} is not a live stream.", videoId); + return Results.Ok(); + } + + if (lastPostedStreamStore.HasValue(videoId)) + { + logger.LogInformation("Video ID {VideoId} has already been posted. Skipping notification.", videoId); + return Results.Ok(); + } + + lastPostedStreamStore.SetValue(videoId); + + logger.LogInformation( + "Video ID {VideoId} is a live stream. Creating discord message in channel {ChannelId}", + videoId, + discordNotificationOptions.CurrentValue.ChannelId + ); + + var msg = string.Format( + CultureInfo.InvariantCulture, + discordNotificationOptions.CurrentValue.MessageFormat, + videoId + ); + + await discordRestClient.CreateMessageAsync( + channelId: discordNotificationOptions.CurrentValue.ChannelId, + request: new CreateMessageRequest( + Content: msg, + MessageReference: null + ), + cancellationToken: cancellationToken + ); + + return Results.Ok(); + } +} + +internal static partial class VideoIdRegex +{ + [GeneratedRegex(@"(.*?)")] + public static partial Regex Regex(); +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Handlers/VerifySubscriptionHandler.cs b/src/src/StevesBot.Webhook/YouTube/Handlers/VerifySubscriptionHandler.cs new file mode 100644 index 0000000..be548f6 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Handlers/VerifySubscriptionHandler.cs @@ -0,0 +1,65 @@ +namespace StevesBot.Webhook.YouTube.Handlers; + +internal static class VerifySubscriptionHandler +{ + public static IResult Handle( + [FromQuery(Name = "hub.mode")] string mode, + [FromQuery(Name = "hub.topic")] string topic, + [FromQuery(Name = "hub.reason")] string? reason, + [FromQuery(Name = "hub.challenge")] string? challenge, + [FromQuery(Name = "hub.lease_seconds")] string? leaseSeconds, + [FromServices] IOptions subOptions, + [FromServices] ILogger logger, + [FromServices] ConcurrentQueue subscriptionQueue, + [FromServices] TimeProvider timeProvider + ) + { + if (mode is "denied") + { + logger.LogInformation("Received subscription denial for topic: {Topic}, reason: {Reason}", topic, reason); + return Results.BadRequest("Subscription denied"); + } + + if (string.Equals(topic, subOptions.Value.TopicUrl, StringComparison.OrdinalIgnoreCase) is false) + { + logger.LogInformation("Received verification request for wrong topic: {Topic}", topic); + return Results.NotFound(); + } + + if (mode is "subscribe") + { + logger.LogInformation( + "Received subscription request for topic: {Topic}, challenge: {Challenge}, lease: {LeaseSeconds}", + topic, + challenge, + leaseSeconds + ); + + if (string.IsNullOrWhiteSpace(leaseSeconds) || long.TryParse(leaseSeconds, out var parsedSeconds) is false) + { + logger.LogWarning("Invalid or missing lease_seconds parameter: {LeaseSeconds}", leaseSeconds); + return Results.BadRequest("Invalid lease_seconds parameter"); + } + + var task = new SubscribeTask + { + CallbackUrl = subOptions.Value.CallbackUrl, + TopicUrl = topic, + ExpiresAt = timeProvider.GetUtcNow().AddSeconds(parsedSeconds), + }; + + subscriptionQueue.Enqueue(task); + } + + if (mode is "unsubscribe") + { + logger.LogInformation( + "Received unsubscription request for topic: {Topic}, challenge: {Challenge}", + topic, + challenge + ); + } + + return Results.Text(challenge); + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/ILastPostedStreamStore.cs b/src/src/StevesBot.Webhook/YouTube/ILastPostedStreamStore.cs new file mode 100644 index 0000000..84a85f7 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/ILastPostedStreamStore.cs @@ -0,0 +1,7 @@ +namespace StevesBot.Webhook.YouTube; + +internal interface ILastPostedStreamStore +{ + void SetValue(string value); + bool HasValue(string value); +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/IPubSubClient.cs b/src/src/StevesBot.Webhook/YouTube/IPubSubClient.cs new file mode 100644 index 0000000..1d19566 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/IPubSubClient.cs @@ -0,0 +1,10 @@ +namespace StevesBot.Webhook.YouTube; + +internal interface IPubSubClient +{ + Task SubscribeAsync( + string callbackUrl, + string topicUrl, + CancellationToken cancellationToken = default + ); +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/LastPostedStreamStore.cs b/src/src/StevesBot.Webhook/YouTube/LastPostedStreamStore.cs new file mode 100644 index 0000000..4ab8975 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/LastPostedStreamStore.cs @@ -0,0 +1,18 @@ +namespace StevesBot.Webhook.YouTube; + +internal class LastPostedStreamStore : ILastPostedStreamStore +{ + private string _value = string.Empty; + + public void SetValue(string value) + { + ArgumentNullException.ThrowIfNull(value); + _value = value; + } + + public bool HasValue(string value) + { + ArgumentNullException.ThrowIfNull(value); + return _value.Equals(value, StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/PubSubClient.cs b/src/src/StevesBot.Webhook/YouTube/PubSubClient.cs new file mode 100644 index 0000000..0329764 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/PubSubClient.cs @@ -0,0 +1,39 @@ +namespace StevesBot.Webhook.YouTube; + +internal sealed class PubSubClient( + HttpClient httpClient, + ILogger logger +) : IPubSubClient +{ + private const string SubscribeEndpoint = "subscribe"; + private readonly HttpClient _httpClient = httpClient; + private readonly ILogger _logger = logger; + + public async Task SubscribeAsync(string callbackUrl, string topicUrl, CancellationToken cancellationToken = default) + { + var uri = new Uri(SubscribeEndpoint, UriKind.Relative); + var formFields = new Dictionary() + { + { "hub.callback", callbackUrl }, + { "hub.topic", topicUrl }, + { "hub.verify", "async" }, + { "hub.mode", "subscribe" } + }; + using var form = new FormUrlEncodedContent(formFields); + var response = await _httpClient.PostAsync(uri, form, cancellationToken); + + if (response.IsSuccessStatusCode is false) + { + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + + _logger.LogDebug( + "Failed to subscribe to topic {TopicUrl} with callback {CallbackUrl}: {ErrorMessage}", + topicUrl, + callbackUrl, + responseContent + ); + } + + return response.IsSuccessStatusCode; + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/PubSubClientOptions.cs b/src/src/StevesBot.Webhook/YouTube/PubSubClientOptions.cs new file mode 100644 index 0000000..0ec922a --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/PubSubClientOptions.cs @@ -0,0 +1,6 @@ +namespace StevesBot.Webhook.YouTube; + +internal sealed class PubSubClientOptions +{ + public string BaseUrl { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/SubscriptionOptions.cs b/src/src/StevesBot.Webhook/YouTube/SubscriptionOptions.cs new file mode 100644 index 0000000..f64cb56 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/SubscriptionOptions.cs @@ -0,0 +1,10 @@ +namespace StevesBot.Webhook.YouTube; + +internal sealed record SubscriptionOptions +{ + [Required] + public string CallbackUrl { get; init; } = string.Empty; + + [Required] + public string TopicUrl { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/SubscriptionWorker.cs b/src/src/StevesBot.Webhook/YouTube/SubscriptionWorker.cs new file mode 100644 index 0000000..5bb087b --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/SubscriptionWorker.cs @@ -0,0 +1,120 @@ +namespace StevesBot.Webhook.YouTube; + +internal sealed class SubscriptionWorker( + ILogger logger, + IOptions options, + IPubSubClient pubSubClient, + ConcurrentQueue subscriptionQueue, + TimeProvider timeProvider +) : IHostedLifecycleService, IDisposable +{ + private readonly ILogger _logger = logger; + private readonly SubscriptionOptions _options = options.Value; + private readonly IPubSubClient _pubSubClient = pubSubClient; + private readonly ConcurrentQueue _subscriptionQueue = subscriptionQueue; + private readonly TimeProvider _timeProvider = timeProvider; + private ITimer? _subscriptionTimer; + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting subscription worker"); + + _subscriptionTimer = _timeProvider.CreateTimer( + callback: async _ => await ProcessSubscriptionQueueAsync(cancellationToken), + state: null, + dueTime: TimeSpan.FromSeconds(0), + period: TimeSpan.FromDays(1) + ); + + return Task.CompletedTask; + } + + public Task StartingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public async Task StartedAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Subscribing to notifications"); + + var isSubscribed = await _pubSubClient.SubscribeAsync( + _options.CallbackUrl, + _options.TopicUrl, + cancellationToken + ); + + if (isSubscribed) + { + _logger.LogInformation("Successfully subscribed to notifications"); + return; + } + + _logger.LogWarning("Failed to subscribe to notifications"); + } + + + public Task StopAsync(CancellationToken cancellationToken) + { + _subscriptionTimer?.Change(Timeout.InfiniteTimeSpan, TimeSpan.Zero); + return Task.CompletedTask; + } + + public Task StoppingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StoppedAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task ProcessSubscriptionQueueAsync(CancellationToken cancellationToken) + { + while (_subscriptionQueue.TryDequeue(out var task)) + { + // We will attempt to process the task + // once every day so we will aim to + // process it two days before it expires. + var now = _timeProvider.GetUtcNow(); + var dueAt = task.ExpiresAt - TimeSpan.FromDays(2); + var isDue = now >= dueAt; + + if (isDue is false) + { + _logger.LogInformation( + "Skipping subscription task for topic {TopicUrl} as it is not due yet. Due at: {DueAt}, Current time: {CurrentTime}", + task.TopicUrl, + dueAt, + now + ); + + _subscriptionQueue.Enqueue(task); + + continue; + } + + _logger.LogInformation("Processing subscription task for topic: {TopicUrl}", task.TopicUrl); + + var isSubscribed = await _pubSubClient.SubscribeAsync( + task.CallbackUrl, + task.TopicUrl, + cancellationToken + ); + + if (isSubscribed) + { + _logger.LogInformation("Successfully subscribed to topic: {TopicUrl}", task.TopicUrl); + return; + } + + _logger.LogWarning("Failed to subscribe to topic: {TopicUrl}", task.TopicUrl); + } + } + + public void Dispose() + { + _subscriptionTimer?.Dispose(); + } +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/Tasks/SubscribeTask.cs b/src/src/StevesBot.Webhook/YouTube/Tasks/SubscribeTask.cs new file mode 100644 index 0000000..ffba96a --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/Tasks/SubscribeTask.cs @@ -0,0 +1,8 @@ +namespace StevesBot.Webhook.YouTube.Tasks; + +internal sealed record SubscribeTask +{ + public string CallbackUrl { get; init; } = string.Empty; + public string TopicUrl { get; init; } = string.Empty; + public DateTimeOffset ExpiresAt { get; init; } +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/YouTubeClientOptions.cs b/src/src/StevesBot.Webhook/YouTube/YouTubeClientOptions.cs new file mode 100644 index 0000000..78828f3 --- /dev/null +++ b/src/src/StevesBot.Webhook/YouTube/YouTubeClientOptions.cs @@ -0,0 +1,10 @@ +namespace StevesBot.Webhook.YouTube; + +internal sealed record YouTubeClientOptions +{ + [Required] + public string BaseUrl { get; init; } = string.Empty; + + [Required] + public string ApiKey { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/appsettings.Example.json b/src/src/StevesBot.Webhook/appsettings.Example.json new file mode 100644 index 0000000..eb9bcb2 --- /dev/null +++ b/src/src/StevesBot.Webhook/appsettings.Example.json @@ -0,0 +1,34 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "YouTubeClientOptions": { + "BaseUrl": "BaseUrl", + "ApiKey": "ApiKey" + }, + "SubscriptionOptions": { + "CallbackUrl": "CallbackUrl", + "TopicUrl": "TopicUrl" + }, + "PubSubClientOptions": { + "BaseUrl": "BaseUrl" + }, + "DiscordClientOptions": { + "ApiUrl": "ApiUrl", + "AppToken": "AppToken", + "Intents": 0 + }, + "DiscordNotificationOptions": { + "ChannelId": "ChannelId", + "MessageFormat": "MessageFormat" + }, + "SeqOptions": { + "ServerUrl": "ServerUrl", + "ApiKeyHeader": "ApiKeyHeader", + "ApiKey": "ApiKey" + } +} diff --git a/src/StevesBot.Worker/Discord/Extensions.cs b/src/src/StevesBot.Worker/Discord/Extensions.cs similarity index 62% rename from src/StevesBot.Worker/Discord/Extensions.cs rename to src/src/StevesBot.Worker/Discord/Extensions.cs index 5271625..0bf3be6 100644 --- a/src/StevesBot.Worker/Discord/Extensions.cs +++ b/src/src/StevesBot.Worker/Discord/Extensions.cs @@ -2,21 +2,6 @@ namespace StevesBot.Worker.Discord; internal static class Extensions { - public static IServiceCollection AddDiscordRestClient(this IServiceCollection services) - { - services - .AddHttpClient(static (sp, c) => - { - var discordOptions = sp.GetRequiredService(); - c.BaseAddress = new Uri(discordOptions.ApiUrl); - c.DefaultRequestHeaders.Authorization = new("Bot", discordOptions.AppToken); - c.DefaultRequestHeaders.Add("User-Agent", $"DiscordBot (https://github.com/StevanFreeborn/steves-bot, {Instrumentation.SourceVersion})"); - }) - .AddStandardResilienceHandler(); - - return services; - } - public static IServiceCollection AddDiscordGatewayClient(this IServiceCollection services, Action? configure = null) { services.AddSingleton(sp => diff --git a/src/StevesBot.Worker/Discord/Gateway/DiscordCloseCodes.cs b/src/src/StevesBot.Worker/Discord/Gateway/DiscordCloseCodes.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/DiscordCloseCodes.cs rename to src/src/StevesBot.Worker/Discord/Gateway/DiscordCloseCodes.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/DiscordGatewayClient.cs b/src/src/StevesBot.Worker/Discord/Gateway/DiscordGatewayClient.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/DiscordGatewayClient.cs rename to src/src/StevesBot.Worker/Discord/Gateway/DiscordGatewayClient.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/DiscordGatewayClientException.cs b/src/src/StevesBot.Worker/Discord/Gateway/DiscordGatewayClientException.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/DiscordGatewayClientException.cs rename to src/src/StevesBot.Worker/Discord/Gateway/DiscordGatewayClientException.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/DiscordIntents.cs b/src/src/StevesBot.Worker/Discord/Gateway/DiscordIntents.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/DiscordIntents.cs rename to src/src/StevesBot.Worker/Discord/Gateway/DiscordIntents.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/Data/Activity.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/Data/Activity.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/Data/Activity.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/Data/Activity.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/Data/ActivityType.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/Data/ActivityType.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/Data/ActivityType.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/Data/ActivityType.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/Data/HelloData.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/Data/HelloData.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/Data/HelloData.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/Data/HelloData.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/Data/IdentifyData.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/Data/IdentifyData.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/Data/IdentifyData.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/Data/IdentifyData.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/Data/IdentifyProperties.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/Data/IdentifyProperties.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/Data/IdentifyProperties.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/Data/IdentifyProperties.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/Data/PresenceStatus.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/Data/PresenceStatus.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/Data/PresenceStatus.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/Data/PresenceStatus.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/Data/ReadyData.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/Data/ReadyData.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/Data/ReadyData.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/Data/ReadyData.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/Data/ResumeData.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/Data/ResumeData.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/Data/ResumeData.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/Data/ResumeData.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/Data/UpdatePresenceData.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/Data/UpdatePresenceData.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/Data/UpdatePresenceData.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/Data/UpdatePresenceData.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/DiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/DiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/DiscordEventConverter.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordEventConverter.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/DiscordEventConverter.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordEventConverter.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/DiscordEventTypes.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordEventTypes.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/DiscordEventTypes.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordEventTypes.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/DiscordMessageTypes.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordMessageTypes.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/DiscordMessageTypes.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordMessageTypes.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/DiscordOpCodes.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordOpCodes.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/DiscordOpCodes.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/DiscordOpCodes.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/DispatchDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/DispatchDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/DispatchDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/DispatchDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/HeartbeatAckDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/HeartbeatAckDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/HeartbeatAckDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/HeartbeatAckDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/HeartbeatDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/HeartbeatDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/HeartbeatDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/HeartbeatDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/HelloDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/HelloDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/HelloDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/HelloDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/IdentifyDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/IdentifyDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/IdentifyDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/IdentifyDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/InvalidSessionDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/InvalidSessionDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/InvalidSessionDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/InvalidSessionDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/MessageCreateDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/MessageCreateDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/MessageCreateDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/MessageCreateDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/ReadyDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/ReadyDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/ReadyDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/ReadyDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/ReconnectDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/ReconnectDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/ReconnectDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/ReconnectDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/ResumeDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/ResumeDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/ResumeDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/ResumeDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/Events/UpdatePresenceDiscordEvent.cs b/src/src/StevesBot.Worker/Discord/Gateway/Events/UpdatePresenceDiscordEvent.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/Events/UpdatePresenceDiscordEvent.cs rename to src/src/StevesBot.Worker/Discord/Gateway/Events/UpdatePresenceDiscordEvent.cs diff --git a/src/StevesBot.Worker/Discord/Gateway/IDiscordGatewayClient.cs b/src/src/StevesBot.Worker/Discord/Gateway/IDiscordGatewayClient.cs similarity index 100% rename from src/StevesBot.Worker/Discord/Gateway/IDiscordGatewayClient.cs rename to src/src/StevesBot.Worker/Discord/Gateway/IDiscordGatewayClient.cs diff --git a/src/StevesBot.Worker/Handlers/WelcomeMessageHandler.cs b/src/src/StevesBot.Worker/Handlers/WelcomeMessageHandler.cs similarity index 100% rename from src/StevesBot.Worker/Handlers/WelcomeMessageHandler.cs rename to src/src/StevesBot.Worker/Handlers/WelcomeMessageHandler.cs diff --git a/src/StevesBot.Worker/Program.cs b/src/src/StevesBot.Worker/Program.cs similarity index 91% rename from src/StevesBot.Worker/Program.cs rename to src/src/StevesBot.Worker/Program.cs index 722a736..c0c6111 100644 --- a/src/StevesBot.Worker/Program.cs +++ b/src/src/StevesBot.Worker/Program.cs @@ -11,7 +11,7 @@ builder.Services.AddSingleton(static sp => return discordOptions; }); -builder.AddTelemetry(); +builder.AddTelemetry(static () => new StevesBotWorkerInstrumentation()); builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); diff --git a/src/StevesBot.Worker/Properties/launchSettings.json b/src/src/StevesBot.Worker/Properties/launchSettings.json similarity index 100% rename from src/StevesBot.Worker/Properties/launchSettings.json rename to src/src/StevesBot.Worker/Properties/launchSettings.json diff --git a/src/src/StevesBot.Worker/StevesBot.Worker.csproj b/src/src/StevesBot.Worker/StevesBot.Worker.csproj new file mode 100644 index 0000000..026278e --- /dev/null +++ b/src/src/StevesBot.Worker/StevesBot.Worker.csproj @@ -0,0 +1,16 @@ + + + + dotnet-StevesBot.Worker-0b7a94ae-5cbd-406d-8422-1c791715ef7d + + + + + + + + + + + + diff --git a/src/src/StevesBot.Worker/Telemetry/StevesBotWorkerInstrumentation.cs b/src/src/StevesBot.Worker/Telemetry/StevesBotWorkerInstrumentation.cs new file mode 100644 index 0000000..5bc4663 --- /dev/null +++ b/src/src/StevesBot.Worker/Telemetry/StevesBotWorkerInstrumentation.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; + +namespace StevesBot.Worker.Telemetry; + +internal sealed class StevesBotWorkerInstrumentation : IInstrumentation +{ + private const string SourceNameValue = "StevesBot.Worker"; + private const string SourceVersionValue = "0.0.0"; + + public string SourceName { get; } = SourceNameValue; + public string SourceVersion { get; } = SourceVersionValue; + public ActivitySource Source { get; } = new ActivitySource(SourceNameValue, SourceVersionValue); + + public void Dispose() + { + Source.Dispose(); + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Threading/AsyncLock.cs b/src/src/StevesBot.Worker/Threading/AsyncLock.cs similarity index 100% rename from src/StevesBot.Worker/Threading/AsyncLock.cs rename to src/src/StevesBot.Worker/Threading/AsyncLock.cs diff --git a/src/StevesBot.Worker/Threading/IAsyncLock.cs b/src/src/StevesBot.Worker/Threading/IAsyncLock.cs similarity index 100% rename from src/StevesBot.Worker/Threading/IAsyncLock.cs rename to src/src/StevesBot.Worker/Threading/IAsyncLock.cs diff --git a/src/StevesBot.Worker/Threading/LockReleaser.cs b/src/src/StevesBot.Worker/Threading/LockReleaser.cs similarity index 100% rename from src/StevesBot.Worker/Threading/LockReleaser.cs rename to src/src/StevesBot.Worker/Threading/LockReleaser.cs diff --git a/src/StevesBot.Worker/Usings.cs b/src/src/StevesBot.Worker/Usings.cs similarity index 59% rename from src/StevesBot.Worker/Usings.cs rename to src/src/StevesBot.Worker/Usings.cs index 2466a9e..7dce256 100644 --- a/src/StevesBot.Worker/Usings.cs +++ b/src/src/StevesBot.Worker/Usings.cs @@ -1,5 +1,3 @@ -global using System.Diagnostics; -global using System.Net.Http.Json; global using System.Net.WebSockets; global using System.Reflection; global using System.Text; @@ -8,20 +6,16 @@ global using System.Text.Json.Serialization; global using Microsoft.Extensions.Options; -global using OpenTelemetry.Exporter; -global using OpenTelemetry.Logs; -global using OpenTelemetry.Resources; -global using OpenTelemetry.Trace; - +global using StevesBot.Library.Discord; +global using StevesBot.Library.Discord.Common; +global using StevesBot.Library.Discord.Rest; +global using StevesBot.Library.Discord.Rest.Requests; +global using StevesBot.Library.Telemetry; global using StevesBot.Worker; global using StevesBot.Worker.Discord; global using StevesBot.Worker.Discord.Gateway; global using StevesBot.Worker.Discord.Gateway.Events; global using StevesBot.Worker.Discord.Gateway.Events.Data; -global using StevesBot.Worker.Discord.Rest; -global using StevesBot.Worker.Discord.Rest.Requests; -global using StevesBot.Worker.Discord.Rest.Responses; -global using StevesBot.Worker.Discord.Shared; global using StevesBot.Worker.Handlers; global using StevesBot.Worker.Telemetry; global using StevesBot.Worker.Threading; diff --git a/src/StevesBot.Worker/WebSockets/IWebSocket.cs b/src/src/StevesBot.Worker/WebSockets/IWebSocket.cs similarity index 100% rename from src/StevesBot.Worker/WebSockets/IWebSocket.cs rename to src/src/StevesBot.Worker/WebSockets/IWebSocket.cs diff --git a/src/StevesBot.Worker/WebSockets/IWebSocketFactory.cs b/src/src/StevesBot.Worker/WebSockets/IWebSocketFactory.cs similarity index 100% rename from src/StevesBot.Worker/WebSockets/IWebSocketFactory.cs rename to src/src/StevesBot.Worker/WebSockets/IWebSocketFactory.cs diff --git a/src/StevesBot.Worker/WebSockets/WebSocket.cs b/src/src/StevesBot.Worker/WebSockets/WebSocket.cs similarity index 100% rename from src/StevesBot.Worker/WebSockets/WebSocket.cs rename to src/src/StevesBot.Worker/WebSockets/WebSocket.cs diff --git a/src/StevesBot.Worker/WebSockets/WebSocketFactory.cs b/src/src/StevesBot.Worker/WebSockets/WebSocketFactory.cs similarity index 100% rename from src/StevesBot.Worker/WebSockets/WebSocketFactory.cs rename to src/src/StevesBot.Worker/WebSockets/WebSocketFactory.cs diff --git a/src/StevesBot.Worker/Worker.cs b/src/src/StevesBot.Worker/Worker.cs similarity index 100% rename from src/StevesBot.Worker/Worker.cs rename to src/src/StevesBot.Worker/Worker.cs diff --git a/src/StevesBot.Worker/appsettings.Example.json b/src/src/StevesBot.Worker/appsettings.Example.json similarity index 100% rename from src/StevesBot.Worker/appsettings.Example.json rename to src/src/StevesBot.Worker/appsettings.Example.json diff --git a/src/StevesBot.Worker.Tests/.editorconfig b/src/tests/.editorconfig similarity index 100% rename from src/StevesBot.Worker.Tests/.editorconfig rename to src/tests/.editorconfig diff --git a/src/tests/Directory.Build.props b/src/tests/Directory.Build.props new file mode 100644 index 0000000..1d3cea3 --- /dev/null +++ b/src/tests/Directory.Build.props @@ -0,0 +1,15 @@ + + + + + + false + + + + + + + + diff --git a/src/tests/Directory.Packages.props b/src/tests/Directory.Packages.props new file mode 100644 index 0000000..fb6598f --- /dev/null +++ b/src/tests/Directory.Packages.props @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + + diff --git a/src/tests/StevesBot.Library.Tests/StevesBot.Library.Tests.csproj b/src/tests/StevesBot.Library.Tests/StevesBot.Library.Tests.csproj new file mode 100644 index 0000000..813a84e --- /dev/null +++ b/src/tests/StevesBot.Library.Tests/StevesBot.Library.Tests.csproj @@ -0,0 +1,35 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + true + ./TestResults/Coverage/ + cobertura + [StevesBot.Library]* + + + + + + + diff --git a/src/StevesBot.Worker.Tests/Unit/CreateMessageRequestTests.cs b/src/tests/StevesBot.Library.Tests/Unit/CreateMessageRequestTests.cs similarity index 92% rename from src/StevesBot.Worker.Tests/Unit/CreateMessageRequestTests.cs rename to src/tests/StevesBot.Library.Tests/Unit/CreateMessageRequestTests.cs index 597ee76..2ec8a42 100644 --- a/src/StevesBot.Worker.Tests/Unit/CreateMessageRequestTests.cs +++ b/src/tests/StevesBot.Library.Tests/Unit/CreateMessageRequestTests.cs @@ -1,4 +1,4 @@ -namespace StevesBot.Worker.Tests.Unit; +namespace StevesBot.Library.Tests.Unit; public class CreateMessageRequestTests { diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordClientOptionsTests.cs b/src/tests/StevesBot.Library.Tests/Unit/DiscordClientOptionsTests.cs similarity index 95% rename from src/StevesBot.Worker.Tests/Unit/DiscordClientOptionsTests.cs rename to src/tests/StevesBot.Library.Tests/Unit/DiscordClientOptionsTests.cs index cd2f5c5..dc04065 100644 --- a/src/StevesBot.Worker.Tests/Unit/DiscordClientOptionsTests.cs +++ b/src/tests/StevesBot.Library.Tests/Unit/DiscordClientOptionsTests.cs @@ -1,4 +1,4 @@ -namespace StevesBot.Worker.Tests.Unit; +namespace StevesBot.Library.Tests.Unit; public class DiscordClientOptionsTests { diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordMessageReferenceTests.cs b/src/tests/StevesBot.Library.Tests/Unit/DiscordMessageReferenceTests.cs similarity index 94% rename from src/StevesBot.Worker.Tests/Unit/DiscordMessageReferenceTests.cs rename to src/tests/StevesBot.Library.Tests/Unit/DiscordMessageReferenceTests.cs index d96d002..2d4a356 100644 --- a/src/StevesBot.Worker.Tests/Unit/DiscordMessageReferenceTests.cs +++ b/src/tests/StevesBot.Library.Tests/Unit/DiscordMessageReferenceTests.cs @@ -1,4 +1,4 @@ -namespace StevesBot.Worker.Tests.Unit; +namespace StevesBot.Library.Tests.Unit; public class DiscordMessageReferenceTests { diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordMessageReferenceTypesTests.cs b/src/tests/StevesBot.Library.Tests/Unit/DiscordMessageReferenceTypesTests.cs similarity index 90% rename from src/StevesBot.Worker.Tests/Unit/DiscordMessageReferenceTypesTests.cs rename to src/tests/StevesBot.Library.Tests/Unit/DiscordMessageReferenceTypesTests.cs index cc00aca..e3c2645 100644 --- a/src/StevesBot.Worker.Tests/Unit/DiscordMessageReferenceTypesTests.cs +++ b/src/tests/StevesBot.Library.Tests/Unit/DiscordMessageReferenceTypesTests.cs @@ -1,4 +1,4 @@ -namespace StevesBot.Worker.Tests.Unit; +namespace StevesBot.Library.Tests.Unit; public class DiscordMessageReferenceTypesTests { diff --git a/src/tests/StevesBot.Library.Tests/Unit/DiscordMessageTests.cs b/src/tests/StevesBot.Library.Tests/Unit/DiscordMessageTests.cs new file mode 100644 index 0000000..dd087d4 --- /dev/null +++ b/src/tests/StevesBot.Library.Tests/Unit/DiscordMessageTests.cs @@ -0,0 +1,41 @@ +namespace StevesBot.Library.Tests.Unit; + +public class DiscordMessageTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstance() + { + var result = new DiscordMessage(); + + result.Id.Should().BeEmpty(); + result.Type.Should().Be(0); + result.ChannelId.Should().BeEmpty(); + result.GuildId.Should().BeEmpty(); + result.Author.Should().BeEquivalentTo(new DiscordUser()); + } + + [Fact] + public void Constructor_WhenCalledWithParameters_ItShouldReturnAnInstance() + { + var id = "12345"; + var type = 1; + var channelId = "67890"; + var guildId = "54321"; + var author = new DiscordUser { Id = "11111" }; + + var result = new DiscordMessage + { + Id = id, + Type = type, + ChannelId = channelId, + GuildId = guildId, + Author = author + }; + + result.Id.Should().Be(id); + result.Type.Should().Be(type); + result.ChannelId.Should().Be(channelId); + result.GuildId.Should().Be(guildId); + result.Author.Should().BeEquivalentTo(author); + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordRestClientExceptionTests.cs b/src/tests/StevesBot.Library.Tests/Unit/DiscordRestClientExceptionTests.cs similarity index 95% rename from src/StevesBot.Worker.Tests/Unit/DiscordRestClientExceptionTests.cs rename to src/tests/StevesBot.Library.Tests/Unit/DiscordRestClientExceptionTests.cs index 5860b21..ea3dff3 100644 --- a/src/StevesBot.Worker.Tests/Unit/DiscordRestClientExceptionTests.cs +++ b/src/tests/StevesBot.Library.Tests/Unit/DiscordRestClientExceptionTests.cs @@ -1,4 +1,4 @@ -namespace StevesBot.Worker.Tests.Unit; +namespace StevesBot.Library.Tests.Unit; public class DiscordRestClientExceptionTests { diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordRestClientTests.cs b/src/tests/StevesBot.Library.Tests/Unit/DiscordRestClientTests.cs similarity index 98% rename from src/StevesBot.Worker.Tests/Unit/DiscordRestClientTests.cs rename to src/tests/StevesBot.Library.Tests/Unit/DiscordRestClientTests.cs index 17749d4..02dd99c 100644 --- a/src/StevesBot.Worker.Tests/Unit/DiscordRestClientTests.cs +++ b/src/tests/StevesBot.Library.Tests/Unit/DiscordRestClientTests.cs @@ -1,6 +1,4 @@ -using Microsoft.AspNetCore.Http.HttpResults; - -namespace StevesBot.Worker.Tests.Unit; +namespace StevesBot.Library.Tests.Unit; public sealed class DiscordRestClientTests : IDisposable { diff --git a/src/tests/StevesBot.Library.Tests/Unit/DiscordUserTests.cs b/src/tests/StevesBot.Library.Tests/Unit/DiscordUserTests.cs new file mode 100644 index 0000000..ff0edc0 --- /dev/null +++ b/src/tests/StevesBot.Library.Tests/Unit/DiscordUserTests.cs @@ -0,0 +1,21 @@ +namespace StevesBot.Library.Tests.Unit; + +public class DiscordUserTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstance() + { + var result = new DiscordUser(); + + result.Id.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WhenCalledWithId_ItShouldReturnAnInstanceWithId() + { + var id = "12345"; + var result = new DiscordUser { Id = id }; + + result.Id.Should().Be(id); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Library.Tests/Unit/GatewayResponseTests.cs b/src/tests/StevesBot.Library.Tests/Unit/GatewayResponseTests.cs new file mode 100644 index 0000000..350a81e --- /dev/null +++ b/src/tests/StevesBot.Library.Tests/Unit/GatewayResponseTests.cs @@ -0,0 +1,13 @@ +namespace StevesBot.Library.Tests.Unit; + +public class GatewayResponseTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstance() + { + var url = "https://example.com"; + var result = new GatewayResponse(url); + + result.Url.Should().Be(url); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Library.Tests/Unit/HostExtensionsTests.cs b/src/tests/StevesBot.Library.Tests/Unit/HostExtensionsTests.cs new file mode 100644 index 0000000..76fc960 --- /dev/null +++ b/src/tests/StevesBot.Library.Tests/Unit/HostExtensionsTests.cs @@ -0,0 +1,77 @@ +namespace StevesBot.Library.Tests.Unit; + +public class HostExtensionsTests +{ + [Fact] + public void AddTelemetry_WhenCalledAndSeqOptionsNotConfigured_ItShouldNotAddTelemetry() + { + var builder = WebApplication.CreateBuilder(); + + builder.AddTelemetry(static () => new TestInstrumentation()); + + var app = builder.Build(); + + app.Services + .GetService() + .Should() + .BeNull(); + + app.Services + .GetService() + .Should() + .BeNull(); + + app.Services + .GetService() + .Should() + .BeNull(); + } + + [Fact] + public void AddTelemetry_WhenCalledAndSeqOptionsConfigured_ItShouldAddTelemetry() + { + var builder = WebApplication.CreateBuilder(); + + var json = $@"{{ + ""SeqOptions"": {{ + ""ServerUrl"": ""http://localhost:5341"", + ""ApiKey"": ""my-api-key"", + ""ApiKeyHeader"": ""X-Seq-ApiKey"" + }} + }}"; + + builder.Configuration.AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json))); + + builder.AddTelemetry(static () => new TestInstrumentation()); + + var app = builder.Build(); + + app.Services + .GetService() + .Should() + .NotBeNull(); + + app.Services + .GetServices() + .Where(static p => p is OpenTelemetryLoggerProvider) + .Should() + .NotBeEmpty(); + + app.Services + .GetService() + .Should() + .NotBeNull(); + } + + private sealed class TestInstrumentation : IInstrumentation + { + public string SourceName { get; } = "TestSource"; + public string SourceVersion { get; } = "1.0.0"; + public ActivitySource Source { get; } = new ActivitySource("TestSource", "1.0.0"); + + public void Dispose() + { + Source.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Library.Tests/Unit/SeqOptionsTests.cs b/src/tests/StevesBot.Library.Tests/Unit/SeqOptionsTests.cs new file mode 100644 index 0000000..4d14454 --- /dev/null +++ b/src/tests/StevesBot.Library.Tests/Unit/SeqOptionsTests.cs @@ -0,0 +1,66 @@ +namespace StevesBot.Library.Tests.Unit; + +public class SeqOptionsTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstance() + { + var result = new SeqOptions(); + + result.ServerUrl.Should().Be(string.Empty); + result.ApiKeyHeader.Should().Be(string.Empty); + result.ApiKey.Should().Be(string.Empty); + result.IsEnabled.Should().BeFalse(); + result.LogEndpoint.Should().Be($"{string.Empty}/ingest/otlp/v1/logs"); + result.TraceEndpoint.Should().Be($"{string.Empty}/ingest/otlp/v1/traces"); + result.AuthHeader.Should().Be($"{string.Empty}={string.Empty}"); + } + + [Fact] + public void Constructor_WhenPropertiesSet_ItShouldReturnCorrectValues() + { + var serverUrl = "http://example.com"; + var apiKeyHeader = "ApiKeyHeader"; + var apiKey = "ApiKeyValue"; + + var result = new SeqOptions + { + ServerUrl = serverUrl, + ApiKeyHeader = apiKeyHeader, + ApiKey = apiKey + }; + + result.ServerUrl.Should().Be(serverUrl); + result.ApiKeyHeader.Should().Be(apiKeyHeader); + result.ApiKey.Should().Be(apiKey); + result.IsEnabled.Should().BeTrue(); + result.LogEndpoint.Should().Be($"{serverUrl}/ingest/otlp/v1/logs"); + result.TraceEndpoint.Should().Be($"{serverUrl}/ingest/otlp/v1/traces"); + result.AuthHeader.Should().Be($"{apiKeyHeader}={apiKey}"); + } + + [Theory] + [InlineData(null, null, null, false)] + [InlineData("", "", "", false)] + [InlineData("http://example.com", null, null, false)] + [InlineData("http://example.com", "", "", false)] + [InlineData(null, "ApiKeyHeader", null, false)] + [InlineData(null, null, "ApiKeyValue", false)] + [InlineData("http://example.com", "ApiKeyHeader", "ApiKeyValue", true)] + [InlineData("http://example.com", "", "ApiKeyValue", false)] + [InlineData("", "ApiKeyHeader", "ApiKeyValue", false)] + [InlineData("http://example.com", "ApiKeyHeader", null, false)] + [InlineData("", "ApiKeyHeader", null, false)] + [InlineData(null, "ApiKeyHeader", "ApiKeyValue", false)] + public void IsEnabled_WhenPropertiesAreEmpty_ItShouldReturnFalse(string? serverUrl, string? apiKeyHeader, string? apiKey, bool expected) + { + var result = new SeqOptions + { + ServerUrl = serverUrl!, + ApiKeyHeader = apiKeyHeader!, + ApiKey = apiKey! + }; + + result.IsEnabled.Should().Be(expected); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Library.Tests/Unit/ServicesExtensionsTests.cs b/src/tests/StevesBot.Library.Tests/Unit/ServicesExtensionsTests.cs new file mode 100644 index 0000000..5006291 --- /dev/null +++ b/src/tests/StevesBot.Library.Tests/Unit/ServicesExtensionsTests.cs @@ -0,0 +1,30 @@ +namespace StevesBot.Library.Tests.Unit; + +public class ServicesExtensionsTests +{ + private readonly DiscordClientOptions _discordClientOptions = new() + { + ApiUrl = "https://test.com", + AppToken = "test_token", + }; + private readonly Mock _instrumentationMock = new(); + private readonly ServiceCollection _services = new(); + + public ServicesExtensionsTests() + { + _services.AddSingleton(_instrumentationMock.Object); + _services.AddSingleton(_discordClientOptions); + } + + [Fact] + public void AddDiscordRestClient_WhenCalled_ItShouldAddDiscordRestClient() + { + _services.AddDiscordRestClient(); + + var act = () => _services + .BuildServiceProvider() + .GetRequiredService(); + + act.Should().NotThrow(); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Library.Tests/Usings.cs b/src/tests/StevesBot.Library.Tests/Usings.cs new file mode 100644 index 0000000..398cb8d --- /dev/null +++ b/src/tests/StevesBot.Library.Tests/Usings.cs @@ -0,0 +1,23 @@ +global using System.Diagnostics; +global using System.Net; +global using System.Text; +global using System.Text.Json; + +global using Microsoft.AspNetCore.Builder; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; + +global using Moq; + +global using OpenTelemetry.Logs; +global using OpenTelemetry.Trace; + +global using RichardSzalay.MockHttp; + +global using StevesBot.Library.Discord; +global using StevesBot.Library.Discord.Common; +global using StevesBot.Library.Discord.Rest; +global using StevesBot.Library.Discord.Rest.Requests; +global using StevesBot.Library.Discord.Rest.Responses; +global using StevesBot.Library.Telemetry; \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/StevesBot.Webhook.Tests.csproj b/src/tests/StevesBot.Webhook.Tests/StevesBot.Webhook.Tests.csproj new file mode 100644 index 0000000..b2ad87a --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/StevesBot.Webhook.Tests.csproj @@ -0,0 +1,38 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + true + ./TestResults/Coverage/ + cobertura + [StevesBot.Webhook]* + [StevesBot.Webhook]System.Text.RegularExpressions.Generated.* + **/Program.cs,**/SubscriptionWorker.cs + + + + + + + diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/DiscordNotificationOptionsTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/DiscordNotificationOptionsTests.cs new file mode 100644 index 0000000..8b18ca5 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/DiscordNotificationOptionsTests.cs @@ -0,0 +1,28 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class DiscordNotificationOptionsTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstance() + { + var options = new DiscordNotificationOptions(); + + options.ChannelId.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WhenCalledWithProperties_ItShouldReturnInstance() + { + var channelId = "1234567890"; + var messageFormat = "New video uploaded: {VideoTitle}"; + + var options = new DiscordNotificationOptions + { + ChannelId = channelId, + MessageFormat = messageFormat + }; + + options.ChannelId.Should().Be(channelId); + options.MessageFormat.Should().Be(messageFormat); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStore.cs b/src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStore.cs new file mode 100644 index 0000000..90f1cd1 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStore.cs @@ -0,0 +1,36 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class LastPostedStreamStoreTests +{ + [Fact] + public void SetValue_WhenCalledWithNull_ItShouldThrowArgumentNullException() + { + var store = new LastPostedStreamStore(); + + var act = () => store.SetValue(null!); + + act.Should().Throw(); + } + + [Fact] + public void HasValue_WhenCalledWithNull_ItShouldThrowArgumentNullException() + { + var store = new LastPostedStreamStore(); + + var act = () => store.HasValue(null!); + + act.Should().Throw(); + } + + + [Fact] + public void SetValueHasValue_WhenCalled_ItShouldStoreValue() + { + var store = new LastPostedStreamStore(); + var streamId = "test-stream-id"; + + store.SetValue(streamId); + + store.HasValue(streamId).Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/NotificationHandlerTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/NotificationHandlerTests.cs new file mode 100644 index 0000000..5ba7615 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/NotificationHandlerTests.cs @@ -0,0 +1,205 @@ +using System.Text; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.Options; + +using StevesBot.Library.Discord.Rest; +using StevesBot.Library.Discord.Rest.Requests; +using StevesBot.Webhook.YouTube.Handlers; + +namespace StevesBot.Webhook.Tests.Unit; + +public class NotificationHandlerTests +{ + private readonly Mock _mockHttpRequest = new(); + private readonly Mock _mockHttpContext = new(); + private readonly Mock> _mockLogger = new(); + private readonly Mock _mockYtDataApiClient = new(); + private readonly Mock _mockDiscordRestClient = new(); + private readonly Mock> _mockNotificationsOptions = new(); + private readonly Mock _mockLastPostedStore = new(); + + public NotificationHandlerTests() + { + _mockHttpContext + .Setup(static x => x.Request) + .Returns(_mockHttpRequest.Object); + } + + [Fact] + public async Task HandleAsync_WhenVideoIdNotFound_ItShouldReturnBadRequest() + { + SetupMockRequestBodyStream("This is a test"); + + var result = await HandleAsync(); + + result.Should().BeOfType>(); + } + + [Fact] + public async Task HandleAsync_WhenVideoIsNotFound_ItShouldReturnNotFound() + { + SetupMockRequestBodyStream("videoId"); + + _mockYtDataApiClient + .Setup(static x => x.GetVideoByIdAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + )) + .ReturnsAsync(null as YouTubeVideo); + + var result = await HandleAsync(); + + result.Should().BeOfType(); + } + + [Fact] + public async Task HandleAsync_WhenVideoIsFoundButIsNotALiveStream_ItShouldReturnOkButNotCreateDiscordMessage() + { + SetupMockRequestBodyStream("videoId"); + + _mockYtDataApiClient + .Setup(static x => x.GetVideoByIdAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + )) + .ReturnsAsync(new YouTubeVideo()); + + var result = await HandleAsync(); + + result.Should().BeOfType(); + + _mockLastPostedStore + .Verify(static x => x.SetValue(It.IsAny()), Times.Never); + + _mockDiscordRestClient + .Verify( + static x => x.CreateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Fact] + public async Task HandleAsync_WhenVideoIsFoundButItHasSameIdAsLastVideo_ItShouldReturnOkButNotCreateDiscordMessage() + { + var videoId = "video_id"; + + SetupMockRequestBodyStream($"{videoId}"); + + _mockYtDataApiClient + .Setup(static x => x.GetVideoByIdAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + )) + .ReturnsAsync(new YouTubeVideo() + { + Id = videoId, + LiveStreamingDetails = new(), + Snippet = new() + { + LiveBroadcastContent = "live" + } + }); + + _mockLastPostedStore + .Setup(static x => x.HasValue(It.IsAny())) + .Returns(true); + + var result = await HandleAsync(); + + result.Should().BeOfType(); + + _mockLastPostedStore + .Verify(static x => x.SetValue(It.IsAny()), Times.Never); + + _mockDiscordRestClient + .Verify( + static x => x.CreateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Fact] + public async Task HandleAsync_WhenVideoIsFoundAndItIsANewLiveStream_ItShouldReturnOkStoreTheIdAndCreateADiscordMessage() + { + var videoId = "video_id"; + + SetupMockRequestBodyStream($"{videoId}"); + + _mockYtDataApiClient + .Setup(static x => x.GetVideoByIdAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + )) + .ReturnsAsync(new YouTubeVideo() + { + Id = videoId, + LiveStreamingDetails = new(), + Snippet = new() + { + LiveBroadcastContent = "live" + } + }); + + _mockLastPostedStore + .Setup(static x => x.HasValue(It.IsAny())) + .Returns(false); + + _mockNotificationsOptions + .Setup(x => x.CurrentValue) + .Returns(new DiscordNotificationOptions()); + + var result = await HandleAsync(); + + result.Should().BeOfType(); + + _mockLastPostedStore + .Verify( + x => x.SetValue( + It.Is(s => string.Equals(s, videoId, StringComparison.Ordinal)) + ), + Times.Once + ); + + _mockDiscordRestClient + .Verify( + static x => x.CreateMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Once + ); + } + + private void SetupMockRequestBodyStream(string content) + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + _mockHttpRequest.Setup(static x => x.Body).Returns(stream); + } + + private Task HandleAsync() + { + return NotificationHandler.HandleAsync( + _mockHttpContext.Object, + _mockLogger.Object, + _mockYtDataApiClient.Object, + _mockDiscordRestClient.Object, + _mockNotificationsOptions.Object, + _mockLastPostedStore.Object + ); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/PubSubClientOptionsTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/PubSubClientOptionsTests.cs new file mode 100644 index 0000000..324b5f3 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/PubSubClientOptionsTests.cs @@ -0,0 +1,25 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class PubSubClientOptionsTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstance() + { + var options = new PubSubClientOptions(); + + options.BaseUrl.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WhenCalledWithProperties_ItShouldReturnInstance() + { + var baseUrl = "https://example.com"; + + var options = new PubSubClientOptions() + { + BaseUrl = baseUrl + }; + + options.BaseUrl.Should().Be(baseUrl); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/PubSubClientTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/PubSubClientTests.cs new file mode 100644 index 0000000..f00bac1 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/PubSubClientTests.cs @@ -0,0 +1,36 @@ + +namespace StevesBot.Webhook.Tests.Unit; + +public sealed class PubSubClientTests : IDisposable +{ + private readonly MockHttpMessageHandler _mockHttpMessageHandler = new(); + private readonly Mock> _mockLogger = new(); + private readonly PubSubClient _sut; + + public PubSubClientTests() + { + var mockHttpClient = _mockHttpMessageHandler.ToHttpClient(); + mockHttpClient.BaseAddress = new("https://test.com"); + + _sut = new(mockHttpClient, _mockLogger.Object); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError, false)] + [InlineData(HttpStatusCode.OK, true)] + public async Task SubscribeAsync_WhenCalled_ItShouldReturnExpectedResult(HttpStatusCode givenStatusCode, bool expectedResult) + { + _mockHttpMessageHandler + .When("*/subscribe") + .Respond(givenStatusCode); + + var result = await _sut.SubscribeAsync("callbackUrl", "topicUrl"); + + result.Should().Be(expectedResult); + } + + public void Dispose() + { + _mockHttpMessageHandler.Dispose(); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/StevesBotWebhookInstrumentationTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/StevesBotWebhookInstrumentationTests.cs new file mode 100644 index 0000000..723836b --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/StevesBotWebhookInstrumentationTests.cs @@ -0,0 +1,32 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public sealed class StevesBotWebhookInstrumentationTests : IDisposable +{ + private readonly StevesBotWebhookInstrumentation _instrumentation = new(); + + [Fact] + public void SourceName_WhenCalled_ItShouldReturnCorrectName() + { + _instrumentation.SourceName.Should().Be("StevesBot.Webhook"); + } + + [Fact] + public void SourceVersion_WhenCalled_ItShouldReturnCorrectVersion() + { + _instrumentation.SourceVersion.Should().Be("0.0.0"); + } + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstance() + { + using var result = new StevesBotWebhookInstrumentation(); + using var expectedSource = new ActivitySource(_instrumentation.SourceName, _instrumentation.SourceVersion); + + result.Source.Should().BeEquivalentTo(expectedSource); + } + + public void Dispose() + { + _instrumentation.Dispose(); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/SubscribeTaskTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/SubscribeTaskTests.cs new file mode 100644 index 0000000..2c63a3b --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/SubscribeTaskTests.cs @@ -0,0 +1,33 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class SubscribeTaskTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstance() + { + var task = new SubscribeTask(); + + task.CallbackUrl.Should().BeEmpty(); + task.TopicUrl.Should().BeEmpty(); + task.ExpiresAt.Should().Be(DateTimeOffset.MinValue); + } + + [Fact] + public void Constructor_WhenCalledWithProperties_ItShouldReturnInstance() + { + var callbackUrl = "https://example.com/callback"; + var topicUrl = "https://example.com/topic"; + var expiresAt = DateTime.UtcNow.AddDays(1); + + var task = new SubscribeTask + { + CallbackUrl = callbackUrl, + TopicUrl = topicUrl, + ExpiresAt = expiresAt, + }; + + task.CallbackUrl.Should().Be(callbackUrl); + task.TopicUrl.Should().Be(topicUrl); + task.ExpiresAt.Should().Be(expiresAt); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/SubscriptionOptionsTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/SubscriptionOptionsTests.cs new file mode 100644 index 0000000..f86d1e0 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/SubscriptionOptionsTests.cs @@ -0,0 +1,29 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class SubscriptionOptionsTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstance() + { + var options = new SubscriptionOptions(); + + options.CallbackUrl.Should().BeEmpty(); + options.TopicUrl.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WhenCalledWithProperties_ItShouldReturnInstance() + { + var callbackUrl = "https://example.com/callback"; + var topicUrl = "https://example.com/topic"; + + var options = new SubscriptionOptions + { + CallbackUrl = callbackUrl, + TopicUrl = topicUrl + }; + + options.CallbackUrl.Should().Be(callbackUrl); + options.TopicUrl.Should().Be(topicUrl); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/VerifySubscriptionHandlerTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/VerifySubscriptionHandlerTests.cs new file mode 100644 index 0000000..4c3f278 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/VerifySubscriptionHandlerTests.cs @@ -0,0 +1,123 @@ +using System.Globalization; + +namespace StevesBot.Webhook.Tests.Unit; + +public class VerifySubscriptionHandlerTests +{ + private readonly Mock> _mockSubOptions = new(); + private readonly Mock> _mockLogger = new(); + private readonly ConcurrentQueue _subscriptionQueue = new(); + private readonly Mock _mockTimeProvider = new(); + + [Fact] + public void Handle_WhenModeIsDenied_ItShouldReturnBadRequest() + { + var result = Handle("denied", "topic"); + + result.Should().BeOfType>(); + } + + [Fact] + public void Handle_WhenTopicDoesNotMatch_ItShouldReturnNotFound() + { + SetupSubscriptionOptions(new() { TopicUrl = "expected-topic" }); + + var result = Handle("subscribe", "wrong-topic"); + + result.Should().BeOfType(); + } + + [Fact] + public void Handle_WhenModeIsSubscribeAndLeaseSecondsIsNotPresent_ItShouldReturnBadRequest() + { + var topic = "topic"; + + SetupSubscriptionOptions(new() { TopicUrl = topic }); + + var result = Handle("subscribe", topic, leaseSeconds: null); + + result.Should().BeOfType>(); + } + + [Fact] + public void Handle_WhenModeIsSubscribeAndLeaseSecondsIsInvalid_ItShouldReturnBadRequest() + { + var topic = "topic"; + + SetupSubscriptionOptions(new() { TopicUrl = topic }); + + var result = Handle("subscribe", topic, leaseSeconds: "invalid"); + + result.Should().BeOfType>(); + } + + [Fact] + public void Handle_WhenModeIsUnsubscribe_ItShouldReturnOk() + { + var topic = "topic"; + + SetupSubscriptionOptions(new() { TopicUrl = topic }); + + var result = Handle("unsubscribe", topic, challenge: "challenge"); + + result.Should().BeOfType(); + } + + [Fact] + public void Handle_WhenModeIsSubscribe_ItShouldEnqueueSubscriptionTask() + { + var challenge = "challenge"; + var topic = "topic"; + var leaseSeconds = 3600; + var callbackUrl = "https://example.com/callback"; + var now = DateTime.UtcNow; + + _mockTimeProvider + .Setup(static tp => tp.GetUtcNow()) + .Returns(now); + + SetupSubscriptionOptions(new() { TopicUrl = topic, CallbackUrl = callbackUrl }); + + var result = Handle( + "subscribe", + topic, + challenge: challenge, + leaseSeconds: leaseSeconds.ToString(CultureInfo.InvariantCulture) + ); + + result.Should().BeOfType(); + result.As().ResponseContent.Should().Be(challenge); + + _subscriptionQueue.Count.Should().Be(1); + + var task = _subscriptionQueue.TryDequeue(out var dequeuedTask); + + task.Should().BeTrue(); + dequeuedTask.Should().NotBeNull(); + dequeuedTask!.CallbackUrl.Should().Be(callbackUrl); + dequeuedTask.TopicUrl.Should().Be(topic); + dequeuedTask.ExpiresAt.Should().Be(now.AddSeconds(leaseSeconds)); + } + + private void SetupSubscriptionOptions(SubscriptionOptions options) + { + _mockSubOptions + .Setup(static o => o.Value) + .Returns(options); + } + + private IResult Handle(string mode, string topic, string? reason = null, string? challenge = null, string? leaseSeconds = null) + { + return VerifySubscriptionHandler.Handle( + mode, + topic, + reason, + challenge, + leaseSeconds, + _mockSubOptions.Object, + _mockLogger.Object, + _subscriptionQueue, + _mockTimeProvider.Object + ); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeClientOptionsTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeClientOptionsTests.cs new file mode 100644 index 0000000..2746534 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeClientOptionsTests.cs @@ -0,0 +1,29 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class YouTubeClientOptionsTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstance() + { + var options = new YouTubeClientOptions(); + + options.BaseUrl.Should().BeEmpty(); + options.ApiKey.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WhenCalledWithProperties_ItShouldReturnInstance() + { + var baseUrl = "https://example.com"; + var apiKey = "test-api-key"; + + var options = new YouTubeClientOptions + { + BaseUrl = baseUrl, + ApiKey = apiKey + }; + + options.BaseUrl.Should().Be(baseUrl); + options.ApiKey.Should().Be(apiKey); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeDataApiClientTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeDataApiClientTests.cs new file mode 100644 index 0000000..1021642 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeDataApiClientTests.cs @@ -0,0 +1,81 @@ +using System.Text.Json; + +using Microsoft.Extensions.Options; + +namespace StevesBot.Webhook.Tests.Unit; + +public sealed class YouTubeDataApiClientTests : IDisposable +{ + private readonly MockHttpMessageHandler _mockHttpMessageHandler = new(); + private readonly Mock> _mockLogger = new(); + private readonly Mock> _mockOptions = new(); + private readonly YouTubeDataApiClient _sut; + + public YouTubeDataApiClientTests() + { + var httpClient = _mockHttpMessageHandler.ToHttpClient(); + httpClient.BaseAddress = new Uri("https://test.com"); + + _mockOptions + .Setup(static x => x.Value) + .Returns(new YouTubeClientOptions()); + + _sut = new(httpClient, _mockLogger.Object, _mockOptions.Object); + } + + [Fact] + public async Task GetVideoByIdAsync_WhenRequestFails_ItShouldReturnNull() + { + _mockHttpMessageHandler + .When("*/videos") + .Respond(HttpStatusCode.InternalServerError); + + var result = await _sut.GetVideoByIdAsync("video_id"); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetVideoByIdAsync_WhenRequestSucceedsButItemsEmpty_ItShouldReturnNull() + { + var videosResponse = new YouTubeVideoListResponse(); + + _mockHttpMessageHandler + .When("*/videos") + .Respond( + HttpStatusCode.OK, + "application/json", + JsonSerializer.Serialize(videosResponse) + ); + + var result = await _sut.GetVideoByIdAsync("video_id", ["snippet"]); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetVideoByIdAsync_WhenRequestSucceedsAndVideoFound_ItShouldReturnVideo() + { + var videoId = "video_id"; + var video = new YouTubeVideo() { Id = videoId }; + var videosResponse = new YouTubeVideoListResponse() { Items = [video] }; + + _mockHttpMessageHandler + .When("*/videos") + .Respond( + HttpStatusCode.OK, + "application/json", + JsonSerializer.Serialize(videosResponse) + ); + + var result = await _sut.GetVideoByIdAsync(videoId); + + result.Should().BeEquivalentTo(video); + + } + + public void Dispose() + { + _mockHttpMessageHandler.Dispose(); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeLiveStreamingDetailsTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeLiveStreamingDetailsTests.cs new file mode 100644 index 0000000..004d2c6 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeLiveStreamingDetailsTests.cs @@ -0,0 +1,42 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class YouTubeLiveStreamingDetailsTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstance() + { + var result = new YouTubeLiveStreamingDetails(); + + result.ActualStartTime.Should().BeNull(); + result.ActualEndTime.Should().BeNull(); + result.ScheduledStartTime.Should().BeNull(); + result.ScheduledEndTime.Should().BeNull(); + result.ConcurrentViewers.Should().BeNull(); + result.ActiveLiveChatId.Should().BeNull(); + } + + [Fact] + public void Constructor_WhenCalledWithValues_ItShouldReturnAnInstance() + { + var now = DateTimeOffset.UtcNow; + var viewers = 100UL; + var chatId = "chad_id"; + + var result = new YouTubeLiveStreamingDetails() + { + ActualStartTime = now, + ActualEndTime = now, + ScheduledStartTime = now, + ScheduledEndTime = now, + ConcurrentViewers = viewers, + ActiveLiveChatId = chatId, + }; + + result.ActualStartTime.Should().Be(now); + result.ActualEndTime.Should().Be(now); + result.ScheduledStartTime.Should().Be(now); + result.ScheduledEndTime.Should().Be(now); + result.ConcurrentViewers.Should().Be(viewers); + result.ActiveLiveChatId.Should().Be(chatId); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/YouTubePageInfoTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubePageInfoTests.cs new file mode 100644 index 0000000..5bf628f --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubePageInfoTests.cs @@ -0,0 +1,29 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class YouTubePageInfoTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstance() + { + var result = new YouTubePageInfo(); + + result.TotalResults.Should().Be(0); + result.ResultsPerPage.Should().Be(0); + } + + [Fact] + public void Constructor_WhenCalledWithValues_ItShouldReturnAnInstance() + { + var total = 10; + var resultsPerPage = 1; + + var result = new YouTubePageInfo() + { + TotalResults = total, + ResultsPerPage = resultsPerPage, + }; + + result.TotalResults.Should().Be(total); + result.ResultsPerPage.Should().Be(resultsPerPage); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeSnippetTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeSnippetTests.cs new file mode 100644 index 0000000..1e2a061 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeSnippetTests.cs @@ -0,0 +1,25 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class YouTubeSnippetTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstance() + { + var result = new YouTubeSnippet(); + + result.LiveBroadcastContent.Should().BeEmpty(); + } + + [Fact] + public void Constructor_WhenCalledWithValues_ItShouldReturnAnInstance() + { + var content = "live"; + + var result = new YouTubeSnippet() + { + LiveBroadcastContent = content, + }; + + result.LiveBroadcastContent.Should().Be(content); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeVideoListResponseTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeVideoListResponseTests.cs new file mode 100644 index 0000000..2bf7ea3 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeVideoListResponseTests.cs @@ -0,0 +1,31 @@ +using StevesBot.Webhook.YouTube.Data; + +namespace StevesBot.Webhook.Tests.Unit; + +public class YouTubeVideoListResponseTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstance() + { + var result = new YouTubeVideoListResponse(); + + result.Items.Should().BeEmpty(); + result.PageInfo.Should().BeEquivalentTo(new YouTubePageInfo()); + } + + [Fact] + public void Constructor_WhenCalledWithValues_ItShouldReturnAnInstance() + { + var videos = new YouTubeVideo[] { new() }; + var pageInfo = new YouTubePageInfo(); + + var result = new YouTubeVideoListResponse() + { + Items = videos, + PageInfo = pageInfo, + }; + + result.Items.Should().BeSameAs(videos); + result.PageInfo.Should().BeSameAs(pageInfo); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeVideoTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeVideoTests.cs new file mode 100644 index 0000000..128d5fd --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/YouTubeVideoTests.cs @@ -0,0 +1,68 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class YouTubeVideoTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldReturnAnInstance() + { + var result = new YouTubeVideo(); + + result.Id.Should().BeEmpty(); + result.LiveStreamingDetails.Should().BeNull(); + result.Snippet.Should().BeEquivalentTo(new YouTubeSnippet()); + } + + [Fact] + public void Constructor_WhenCalledWithValues_ItShouldReturnAnInstance() + { + var id = "id"; + var details = new YouTubeLiveStreamingDetails(); + var snippet = new YouTubeSnippet(); + + var result = new YouTubeVideo() + { + Id = id, + LiveStreamingDetails = details, + Snippet = snippet, + }; + + result.Id.Should().Be(id); + result.LiveStreamingDetails.Should().BeSameAs(details); + result.Snippet.Should().BeSameAs(snippet); + } + + [Fact] + public void IsLiveStream_WhenLiveStreamingDetailsIsNull_ItShouldReturnFalse() + { + var video = new YouTubeVideo() + { + Snippet = new() { LiveBroadcastContent = "live" }, + }; + + video.IsLiveStream.Should().BeFalse(); + } + + [Fact] + public void IsLiveStream_WhenSnippetLiveBroadcastContentIsNotLive_ItShouldReturnFalse() + { + var video = new YouTubeVideo() + { + LiveStreamingDetails = new(), + Snippet = new() { LiveBroadcastContent = "none" }, + }; + + video.IsLiveStream.Should().BeFalse(); + } + + [Fact] + public void IsLiveStream_WhenDetailsIsNotNullAndContentIsLive_ItShouldReturnTrue() + { + var video = new YouTubeVideo() + { + LiveStreamingDetails = new(), + Snippet = new() { LiveBroadcastContent = "live" }, + }; + + video.IsLiveStream.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Usings.cs b/src/tests/StevesBot.Webhook.Tests/Usings.cs new file mode 100644 index 0000000..9cba30d --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Usings.cs @@ -0,0 +1,21 @@ +global using System.Collections.Concurrent; +global using System.Diagnostics; +global using System.Net; +global using System.Text; + +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Http.HttpResults; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; + +global using Moq; + +global using RichardSzalay.MockHttp; + +global using StevesBot.Library.Discord.Rest; +global using StevesBot.Library.Discord.Rest.Requests; +global using StevesBot.Webhook.Telemetry; +global using StevesBot.Webhook.YouTube; +global using StevesBot.Webhook.YouTube.Data; +global using StevesBot.Webhook.YouTube.Handlers; +global using StevesBot.Webhook.YouTube.Tasks; \ No newline at end of file diff --git a/src/StevesBot.Worker.Tests/Integration/Infrastructure/TestWebSocketServer.cs b/src/tests/StevesBot.Worker.Tests/Integration/Infrastructure/TestWebSocketServer.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Integration/Infrastructure/TestWebSocketServer.cs rename to src/tests/StevesBot.Worker.Tests/Integration/Infrastructure/TestWebSocketServer.cs diff --git a/src/StevesBot.Worker.Tests/Integration/WebSocketTests.cs b/src/tests/StevesBot.Worker.Tests/Integration/WebSocketTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Integration/WebSocketTests.cs rename to src/tests/StevesBot.Worker.Tests/Integration/WebSocketTests.cs diff --git a/src/tests/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj b/src/tests/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj new file mode 100644 index 0000000..b55d340 --- /dev/null +++ b/src/tests/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj @@ -0,0 +1,39 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + ./TestResults/Coverage/ + cobertura + [StevesBot.Worker]* + **/Program.cs,**/Worker.cs + + + + + + + + + + + + diff --git a/src/StevesBot.Worker.Tests/Unit/ActivityTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/ActivityTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/ActivityTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/ActivityTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/ActivityTypeTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/ActivityTypeTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/ActivityTypeTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/ActivityTypeTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/AsyncLockTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/AsyncLockTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/AsyncLockTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/AsyncLockTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordCloseCodesTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/DiscordCloseCodesTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/DiscordCloseCodesTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/DiscordCloseCodesTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordEventConverterTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/DiscordEventConverterTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/DiscordEventConverterTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/DiscordEventConverterTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/DiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/DiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/DiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordEventTypesTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/DiscordEventTypesTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/DiscordEventTypesTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/DiscordEventTypesTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordGatewayClientExceptionTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/DiscordGatewayClientExceptionTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/DiscordGatewayClientExceptionTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/DiscordGatewayClientExceptionTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordGatewayClientTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/DiscordGatewayClientTests.cs similarity index 99% rename from src/StevesBot.Worker.Tests/Unit/DiscordGatewayClientTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/DiscordGatewayClientTests.cs index 2eec841..4e5fa3b 100644 --- a/src/StevesBot.Worker.Tests/Unit/DiscordGatewayClientTests.cs +++ b/src/tests/StevesBot.Worker.Tests/Unit/DiscordGatewayClientTests.cs @@ -1,5 +1,3 @@ -using System.ComponentModel; - namespace StevesBot.Worker.Tests.Unit; public sealed class DiscordGatewayClientTests : IDisposable diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordIntentsTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/DiscordIntentsTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/DiscordIntentsTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/DiscordIntentsTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordOpCodesTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/DiscordOpCodesTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/DiscordOpCodesTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/DiscordOpCodesTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/ExtensionsTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/ExtensionsTests.cs similarity index 86% rename from src/StevesBot.Worker.Tests/Unit/ExtensionsTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/ExtensionsTests.cs index 210d5dc..e017584 100644 --- a/src/StevesBot.Worker.Tests/Unit/ExtensionsTests.cs +++ b/src/tests/StevesBot.Worker.Tests/Unit/ExtensionsTests.cs @@ -24,18 +24,6 @@ public class ExtensionsTests _services.AddSingleton(_mockScopeFactory.Object); } - [Fact] - public void AddDiscordRestClient_WhenCalled_ItShouldAddDiscordRestClient() - { - _services.AddDiscordRestClient(); - - var act = () => _services - .BuildServiceProvider() - .GetRequiredService(); - - act.Should().NotThrow(); - } - [Fact] public void AddDiscordGatewayClient_WhenCalled_ItShouldAddDiscordGatewayClient() { diff --git a/src/StevesBot.Worker.Tests/Unit/HeartbeatAckDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/HeartbeatAckDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/HeartbeatAckDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/HeartbeatAckDiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/HeartbeatDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/HeartbeatDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/HeartbeatDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/HeartbeatDiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/HelloDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/HelloDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/HelloDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/HelloDiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/IdentifyDataTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/IdentifyDataTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/IdentifyDataTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/IdentifyDataTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/IdentifyDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/IdentifyDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/IdentifyDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/IdentifyDiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/InvalidSessionDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/InvalidSessionDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/InvalidSessionDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/InvalidSessionDiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/LockReleaserTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/LockReleaserTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/LockReleaserTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/LockReleaserTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/MessageCreateDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/MessageCreateDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/MessageCreateDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/MessageCreateDiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/PresenceStatusTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/PresenceStatusTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/PresenceStatusTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/PresenceStatusTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/ReadyDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/ReadyDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/ReadyDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/ReadyDiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/ReconnectDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/ReconnectDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/ReconnectDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/ReconnectDiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/ResumeDataTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/ResumeDataTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/ResumeDataTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/ResumeDataTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/ResumeDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/ResumeDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/ResumeDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/ResumeDiscordEventTests.cs diff --git a/src/tests/StevesBot.Worker.Tests/Unit/StevesBotWorkerInstrumentationTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/StevesBotWorkerInstrumentationTests.cs new file mode 100644 index 0000000..9326314 --- /dev/null +++ b/src/tests/StevesBot.Worker.Tests/Unit/StevesBotWorkerInstrumentationTests.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; + +namespace StevesBot.Worker.Tests.Unit; + +public sealed class StevesBotWorkerInstrumentationTests : IDisposable +{ + private readonly StevesBotWorkerInstrumentation _instrumentation = new(); + + [Fact] + public void SourceName_WhenCalled_ItShouldReturnCorrectName() + { + _instrumentation.SourceName.Should().Be("StevesBot.Worker"); + } + + [Fact] + public void SourceVersion_WhenCalled_ItShouldReturnCorrectVersion() + { + _instrumentation.SourceVersion.Should().Be("0.0.0"); + } + + [Fact] + public void Constructor_WhenCalled_ItShouldReturnInstance() + { + using var result = new StevesBotWorkerInstrumentation(); + using var expectedSource = new ActivitySource(_instrumentation.SourceName, _instrumentation.SourceVersion); + + result.Source.Should().BeEquivalentTo(expectedSource); + } + + public void Dispose() + { + _instrumentation.Dispose(); + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker.Tests/Unit/UpdatePresenceDataTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/UpdatePresenceDataTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/UpdatePresenceDataTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/UpdatePresenceDataTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/UpdatePresenceDiscordEventTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/UpdatePresenceDiscordEventTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/UpdatePresenceDiscordEventTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/UpdatePresenceDiscordEventTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/WebSocketFactoryTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/WebSocketFactoryTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/WebSocketFactoryTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/WebSocketFactoryTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/WebSocketTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/WebSocketTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/WebSocketTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/WebSocketTests.cs diff --git a/src/StevesBot.Worker.Tests/Unit/WelcomeMessageHandlerTests.cs b/src/tests/StevesBot.Worker.Tests/Unit/WelcomeMessageHandlerTests.cs similarity index 100% rename from src/StevesBot.Worker.Tests/Unit/WelcomeMessageHandlerTests.cs rename to src/tests/StevesBot.Worker.Tests/Unit/WelcomeMessageHandlerTests.cs diff --git a/src/StevesBot.Worker.Tests/Usings.cs b/src/tests/StevesBot.Worker.Tests/Usings.cs similarity index 80% rename from src/StevesBot.Worker.Tests/Usings.cs rename to src/tests/StevesBot.Worker.Tests/Usings.cs index 3e07822..7bbe18c 100644 --- a/src/StevesBot.Worker.Tests/Usings.cs +++ b/src/tests/StevesBot.Worker.Tests/Usings.cs @@ -1,4 +1,3 @@ -global using System.Net; global using System.Net.WebSockets; global using System.Text; global using System.Text.Json; @@ -14,15 +13,14 @@ global using Microsoft.Extensions.Logging; global using Moq; -global using RichardSzalay.MockHttp; - +global using StevesBot.Library.Discord.Common; +global using StevesBot.Library.Discord.Rest; +global using StevesBot.Library.Discord.Rest.Requests; global using StevesBot.Worker.Discord; global using StevesBot.Worker.Discord.Gateway; global using StevesBot.Worker.Discord.Gateway.Events; global using StevesBot.Worker.Discord.Gateway.Events.Data; -global using StevesBot.Worker.Discord.Rest; -global using StevesBot.Worker.Discord.Rest.Requests; -global using StevesBot.Worker.Discord.Shared; +global using StevesBot.Worker.Telemetry; global using StevesBot.Worker.Tests.Integration.Infrastructure; global using StevesBot.Worker.Threading; global using StevesBot.Worker.WebSockets; \ No newline at end of file