Merge pull request #3 from StevanFreeborn/stevanfreeborn/feat/add-stream-notification
feat: add stream notification
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<RepoRoot>$(MSBuildThisFileDirectory)</RepoRoot>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
[*.cs]
|
||||
dotnet_diagnostic.CA1056.severity = none
|
||||
dotnet_diagnostic.CA1054.severity = none
|
||||
+2
-2
@@ -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;
|
||||
+4
-2
@@ -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;
|
||||
+4
-2
@@ -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;
|
||||
}
|
||||
+10
-2
@@ -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;
|
||||
+2
-2
@@ -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()
|
||||
{
|
||||
+5
-2
@@ -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>
|
||||
+24
-8
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
namespace StevesBot.Worker.Telemetry;
|
||||
namespace StevesBot.Library.Telemetry;
|
||||
|
||||
internal sealed class SeqOptions
|
||||
{
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
-15
@@ -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
Reference in New Issue
Block a user