Merge pull request #3 from StevanFreeborn/stevanfreeborn/feat/add-stream-notification

feat: add stream notification
This commit is contained in:
Stevan Freeborn
2025-06-04 17:58:06 -05:00
committed by GitHub
176 changed files with 2578 additions and 279 deletions
+44 -24
View File
@@ -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
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
+4 -2
View File
@@ -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
+89 -30
View File
@@ -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
-1
View File
@@ -1,7 +1,6 @@
<Project>
<PropertyGroup>
<RepoRoot>$(MSBuildThisFileDirectory)</RepoRoot>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
-14
View File
@@ -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"]
+35
View File
@@ -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"]
+35
View File
@@ -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"]
@@ -1,46 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="moq" Version="4.20.72" />
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
</ItemGroup>
<PropertyGroup>
<CollectCoverage>true</CollectCoverage>
<CoverletOutput>./TestResults/Coverage/</CoverletOutput>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
<Include>[StevesBot.Worker]*</Include>
<ExcludeByFile>**/Program.cs,**/Worker.cs</ExcludeByFile>
</PropertyGroup>
<Target Name="GenerateHtmlCoverageReport" AfterTargets="GenerateCoverageResultAfterTest">
<Exec Command="reportgenerator -reports:./TestResults/Coverage/*.xml -targetdir:./TestResults/Coverage/Report/ -reporttypes:Html_Dark" />
</Target>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StevesBot.Worker\StevesBot.Worker.csproj" />
</ItemGroup>
</Project>
@@ -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
);
@@ -1,5 +0,0 @@
namespace StevesBot.Worker.Discord.Rest.Responses;
internal sealed record GatewayResponse(
[property: JsonPropertyName("url")] string Url
);
@@ -1,7 +0,0 @@
namespace StevesBot.Worker.Discord.Shared;
internal static class DiscordMessageReferenceTypes
{
public const int Default = 0;
public const int Forward = 1;
}
@@ -1,7 +0,0 @@
namespace StevesBot.Worker.Discord.Shared;
internal sealed record DiscordUser
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
}
@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<UserSecretsId>dotnet-StevesBot.Worker-0b7a94ae-5cbd-406d-8422-1c791715ef7d</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.5.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
@@ -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();
}
}
+94 -26
View File
@@ -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
+40
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.5" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.5" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="9.5.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
</ItemGroup>
</Project>
+3
View File
@@ -0,0 +1,3 @@
[*.cs]
dotnet_diagnostic.CA1056.severity = none
dotnet_diagnostic.CA1054.severity = none
@@ -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;
@@ -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;
@@ -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,
@@ -0,0 +1,7 @@
namespace StevesBot.Library.Discord.Common;
public static class DiscordMessageReferenceTypes
{
public const int Default = 0;
public const int Forward = 1;
}
@@ -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;
}
@@ -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<DiscordRestClient> _logger;
private readonly HttpClient _httpClient;
@@ -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()
{
@@ -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<string> GetGatewayUrlAsync(CancellationToken cancellationToken = default);
Task<DiscordMessage> CreateMessageAsync(string channelId, CreateMessageRequest request, CancellationToken cancellationToken = default);
@@ -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
);
@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace StevesBot.Library.Discord.Rest.Responses;
public sealed record GatewayResponse(
[property: JsonPropertyName("url")] string Url
);
@@ -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<IDiscordRestClient, DiscordRestClient>(static (sp, c) =>
{
var discordOptions = sp.GetRequiredService<DiscordClientOptions>();
c.BaseAddress = new Uri(discordOptions.ApiUrl);
c.DefaultRequestHeaders.Authorization = new("Bot", discordOptions.AppToken);
var instrumentation = sp.GetService<IInstrumentation>();
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;
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Exporter.Console" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
</ItemGroup>
</Project>
@@ -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<IInstrumentation> instrumentationFunc)
{
builder.Services.AddSingleton<Instrumentation>();
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<IInstrumentation>();
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource =>
{
resource.AddService(Instrumentation.SourceName, Instrumentation.SourceVersion);
resource.AddService(instrumentation.SourceName, instrumentation.SourceVersion);
resource.AddAttributes(new Dictionary<string, object>
{
["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 =>
@@ -0,0 +1,10 @@
using System.Diagnostics;
namespace StevesBot.Library.Telemetry;
public interface IInstrumentation : IDisposable
{
string SourceName { get; }
string SourceVersion { get; }
ActivitySource Source { get; }
}
@@ -1,4 +1,4 @@
namespace StevesBot.Worker.Telemetry;
namespace StevesBot.Library.Telemetry;
internal sealed class SeqOptions
{
+73
View File
@@ -0,0 +1,73 @@
var builder = WebApplication.CreateBuilder(args);
builder.AddTelemetry(static () => new StevesBotWebhookInstrumentation());
builder.Services
.AddOptionsWithValidateOnStart<SubscriptionOptions>()
.BindConfiguration(nameof(SubscriptionOptions))
.ValidateDataAnnotations();
builder.Services
.AddOptionsWithValidateOnStart<YouTubeClientOptions>()
.BindConfiguration(nameof(YouTubeClientOptions))
.ValidateDataAnnotations();
builder.Services
.AddOptionsWithValidateOnStart<PubSubClientOptions>()
.BindConfiguration(nameof(PubSubClientOptions))
.ValidateDataAnnotations();
builder.Services
.AddHttpClient<IPubSubClient, PubSubClient>(
static (sp, c) =>
{
var options = sp.GetRequiredService<IOptions<PubSubClientOptions>>().Value;
c.BaseAddress = new(options.BaseUrl);
}
)
.AddStandardResilienceHandler();
builder.Services
.AddHttpClient<IYouTubeDataApiClient, YouTubeDataApiClient>(
static (sp, c) =>
{
var options = sp.GetRequiredService<IOptions<YouTubeClientOptions>>().Value;
c.BaseAddress = new(options.BaseUrl);
}
)
.AddStandardResilienceHandler();
builder.Services.AddOpenApi();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ConcurrentQueue<SubscribeTask>>();
builder.Services.AddHostedService<SubscriptionWorker>();
builder.Services.AddSingleton<ILastPostedStreamStore, LastPostedStreamStore>();
builder.Services.AddOptionsWithValidateOnStart<DiscordNotificationOptions>()
.BindConfiguration(nameof(DiscordNotificationOptions))
.ValidateDataAnnotations();
builder.Services.AddOptions<DiscordClientOptions>()
.BindConfiguration(nameof(DiscordClientOptions));
builder.Services.AddSingleton(
static sp => sp.GetRequiredService<IOptions<DiscordClientOptions>>().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();
@@ -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"
}
},
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StevesBot.Library\StevesBot.Library.csproj" />
</ItemGroup>
</Project>
@@ -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();
}
}
+20
View File
@@ -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;
@@ -0,0 +1,10 @@
namespace StevesBot.Webhook.YouTube.Data;
internal interface IYouTubeDataApiClient
{
Task<YouTubeVideo?> GetVideoByIdAsync(
string videoId,
string[]? part = null,
CancellationToken cancellationToken = default
);
}
@@ -0,0 +1,52 @@
namespace StevesBot.Webhook.YouTube.Data;
internal sealed class YouTubeDataApiClient(
HttpClient httpClient,
ILogger<YouTubeDataApiClient> logger,
IOptions<YouTubeClientOptions> options
) : IYouTubeDataApiClient
{
private const string VideosEndpoint = "videos";
private readonly HttpClient _httpClient = httpClient;
private readonly ILogger<YouTubeDataApiClient> _logger = logger;
private readonly YouTubeClientOptions _options = options.Value;
public async Task<YouTubeVideo?> GetVideoByIdAsync(
string videoId,
string[]? part = null,
CancellationToken cancellationToken = default
)
{
var queryParams = new Dictionary<string, string?>
{
["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<YouTubeVideoListResponse>(cancellationToken);
return responseContent?.Items.FirstOrDefault(i => i.Id == videoId);
}
}
@@ -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; }
}
@@ -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; }
}
@@ -0,0 +1,7 @@
namespace StevesBot.Webhook.YouTube.Data;
internal sealed record YouTubeSnippet
{
[JsonPropertyName("liveBroadcastContent")]
public string LiveBroadcastContent { get; init; } = string.Empty;
}
@@ -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";
}
@@ -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();
}
@@ -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;
}
@@ -0,0 +1,80 @@
namespace StevesBot.Webhook.YouTube.Handlers;
internal static class NotificationHandler
{
public static async Task<IResult> HandleAsync(
HttpContext context,
[FromServices] ILogger<Program> logger,
[FromServices] IYouTubeDataApiClient youTubeDataApiClient,
[FromServices] IDiscordRestClient discordRestClient,
[FromServices] IOptionsMonitor<DiscordNotificationOptions> 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(@"<yt:videoId>(.*?)</yt:videoId>")]
public static partial Regex Regex();
}
@@ -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<SubscriptionOptions> subOptions,
[FromServices] ILogger<Program> logger,
[FromServices] ConcurrentQueue<SubscribeTask> 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);
}
}
@@ -0,0 +1,7 @@
namespace StevesBot.Webhook.YouTube;
internal interface ILastPostedStreamStore
{
void SetValue(string value);
bool HasValue(string value);
}
@@ -0,0 +1,10 @@
namespace StevesBot.Webhook.YouTube;
internal interface IPubSubClient
{
Task<bool> SubscribeAsync(
string callbackUrl,
string topicUrl,
CancellationToken cancellationToken = default
);
}
@@ -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);
}
}
@@ -0,0 +1,39 @@
namespace StevesBot.Webhook.YouTube;
internal sealed class PubSubClient(
HttpClient httpClient,
ILogger<PubSubClient> logger
) : IPubSubClient
{
private const string SubscribeEndpoint = "subscribe";
private readonly HttpClient _httpClient = httpClient;
private readonly ILogger<PubSubClient> _logger = logger;
public async Task<bool> SubscribeAsync(string callbackUrl, string topicUrl, CancellationToken cancellationToken = default)
{
var uri = new Uri(SubscribeEndpoint, UriKind.Relative);
var formFields = new Dictionary<string, string>()
{
{ "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;
}
}
@@ -0,0 +1,6 @@
namespace StevesBot.Webhook.YouTube;
internal sealed class PubSubClientOptions
{
public string BaseUrl { get; init; } = string.Empty;
}
@@ -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;
}
@@ -0,0 +1,120 @@
namespace StevesBot.Webhook.YouTube;
internal sealed class SubscriptionWorker(
ILogger<SubscriptionWorker> logger,
IOptions<SubscriptionOptions> options,
IPubSubClient pubSubClient,
ConcurrentQueue<SubscribeTask> subscriptionQueue,
TimeProvider timeProvider
) : IHostedLifecycleService, IDisposable
{
private readonly ILogger<SubscriptionWorker> _logger = logger;
private readonly SubscriptionOptions _options = options.Value;
private readonly IPubSubClient _pubSubClient = pubSubClient;
private readonly ConcurrentQueue<SubscribeTask> _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();
}
}
@@ -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; }
}
@@ -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;
}
@@ -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"
}
}
@@ -2,21 +2,6 @@ namespace StevesBot.Worker.Discord;
internal static class Extensions
{
public static IServiceCollection AddDiscordRestClient(this IServiceCollection services)
{
services
.AddHttpClient<IDiscordRestClient, DiscordRestClient>(static (sp, c) =>
{
var discordOptions = sp.GetRequiredService<DiscordClientOptions>();
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<IDiscordGatewayClient>? configure = null)
{
services.AddSingleton<IDiscordGatewayClient>(sp =>
@@ -11,7 +11,7 @@ builder.Services.AddSingleton(static sp =>
return discordOptions;
});
builder.AddTelemetry();
builder.AddTelemetry(static () => new StevesBotWorkerInstrumentation());
builder.Services.AddSingleton<IWebSocketFactory, WebSocketFactory>();
builder.Services.AddSingleton(TimeProvider.System);
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<UserSecretsId>dotnet-StevesBot.Worker-0b7a94ae-5cbd-406d-8422-1c791715ef7d</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StevesBot.Library\StevesBot.Library.csproj" />
</ItemGroup>
</Project>
@@ -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();
}
}
@@ -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;

Some files were not shown because too many files have changed in this diff Show More