Merge pull request #3 from StevanFreeborn/stevanfreeborn/feat/add-stream-notification
feat: add stream notification
This commit is contained in:
176 files changed
+2576
-277
No files matched your search
@@ -8,7 +8,7 @@ on:
|
|||||||
- 'src/**'
|
- 'src/**'
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Docker Image
|
name: Build Docker Images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
@@ -25,14 +25,27 @@ jobs:
|
|||||||
run: echo "version=$(date +%Y.%m.%d.%H%M%S)" >> $GITHUB_OUTPUT
|
run: echo "version=$(date +%Y.%m.%d.%H%M%S)" >> $GITHUB_OUTPUT
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/steves-bot:${{ steps.version.outputs.version }}
|
WORKER_TAG=${{ secrets.DOCKERHUB_USERNAME }}/steves-bot:worker-${{ steps.version.outputs.version }}
|
||||||
docker build -t $TAG src
|
docker build -f src/StevesBot.Worker.Dockerfile -t $WORKER_TAG src
|
||||||
docker push $TAG
|
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:
|
deploy:
|
||||||
name: Deploy Docker Image
|
name: Deploy Docker Images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
steps:
|
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
|
- name: Run image on server
|
||||||
uses: appleboy/ssh-action@v1
|
uses: appleboy/ssh-action@v1
|
||||||
with:
|
with:
|
||||||
@@ -42,25 +55,32 @@ jobs:
|
|||||||
script: |
|
script: |
|
||||||
cd steves-bot
|
cd steves-bot
|
||||||
|
|
||||||
echo 'Logging__LogLevel__Default=Debug' >> .env
|
echo 'WORKER_VERSION=worker-${{ needs.build.outputs.version }}' > .env
|
||||||
echo 'Logging__LogLevel__Microsoft=Debug' >> .env
|
echo 'WEBHOOK_VERSION=webhook-${{ needs.build.outputs.version }}' > .env
|
||||||
echo 'Logging__LogLevel__Microsoft.Hosting.Lifetime=Debug' >> .env
|
|
||||||
echo 'DiscordClientOptions__ApiUrl=${{ vars.DISCORD_API_URL }}' >> .env
|
echo 'DiscordClientOptions__ApiUrl=${{ vars.DISCORD_API_URL }}' >> .env
|
||||||
echo 'DiscordClientOptions__AppToken=${{ secrets.DISCORD_APP_TOKEN }}' >> .env
|
echo 'DiscordClientOptions__AppToken=${{ secrets.DISCORD_APP_TOKEN }}' >> .env
|
||||||
echo 'DiscordClientOptions__Intents=${{ vars.DISCORD_INTENTS }}' >> .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
|
|
||||||
|
|
||||||
docker stop steves-bot
|
echo 'SEQ_SERVER_URL=${{ secrets.SEQ_SERVER_URL }}' >> .env
|
||||||
docker rm steves-bot
|
echo 'SEQ_API_KEY_HEADER=${{ vars.SEQ_API_KEY_HEADER }}' >> .env
|
||||||
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/steves-bot:${{ needs.build.outputs.version }}
|
echo 'SEQ_WORKER_API_KEY=${{ secrets.SEQ_WORKER_API_KEY }}' >> .env
|
||||||
docker run \
|
echo 'SEQ_WEBHOOK_API_KEY=${{ secrets.SEQ_WEBHOOK_API_KEY }}' >> .env
|
||||||
--name steves-bot \
|
|
||||||
--restart always \
|
|
||||||
--env-file .env \
|
|
||||||
-d \
|
|
||||||
${{ secrets.DOCKERHUB_USERNAME }}/steves-bot:${{ needs.build.outputs.version }}
|
|
||||||
|
|
||||||
|
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
|
rm .env
|
||||||
@@ -33,8 +33,10 @@ jobs:
|
|||||||
run: dotnet tool install -g dotnet-reportgenerator-globaltool
|
run: dotnet tool install -g dotnet-reportgenerator-globaltool
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: dotnet test src
|
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
|
- name: Upload code coverage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: html-report
|
name: coverage-reports
|
||||||
path: src/StevesBot.Worker.Tests/TestResults/Coverage/Report/
|
path: src/tests/TestResults/Coverage
|
||||||
@@ -1,17 +1,32 @@
|
|||||||
# Steve's Bot 🤖
|
# 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
|
## ✨ Features
|
||||||
|
|
||||||
|
### Core Bot Platform
|
||||||
|
|
||||||
- **Custom Discord Gateway Client**: Full-featured implementation with:
|
- **Custom Discord Gateway Client**: Full-featured implementation with:
|
||||||
- WebSocket connection management
|
- WebSocket connection management
|
||||||
- Automatic heartbeat handling
|
- Automatic heartbeat handling
|
||||||
- Session resumption and reconnection logic
|
- Session resumption and reconnection logic
|
||||||
- Event-driven architecture
|
- Event-driven architecture
|
||||||
|
- **Shared Library**: Common Discord REST client and telemetry components
|
||||||
- **Observability**: Built-in telemetry with OpenTelemetry support
|
- **Observability**: Built-in telemetry with OpenTelemetry support
|
||||||
- **Resilient Architecture**: Graceful error handling and automatic recovery
|
- **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
|
## 🚀 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)
|
- [.NET 9 SDK](https://dotnet.microsoft.com/download)
|
||||||
- Discord Bot Token (from [Discord Developer Portal](https://discord.com/developers/applications))
|
- 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
|
### Configuration
|
||||||
|
|
||||||
|
#### Worker Service (Discord Bot)
|
||||||
|
|
||||||
1. Copy the example configuration:
|
1. Copy the example configuration:
|
||||||
|
|
||||||
```powershell
|
```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:
|
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
|
#### Using VS Code Tasks
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Build the project
|
# Build the entire solution
|
||||||
dotnet build src/StevesBot.sln
|
dotnet build src/StevesBot.sln
|
||||||
|
|
||||||
# Run the bot
|
# Run the Discord bot worker
|
||||||
dotnet run --project src/StevesBot.Worker
|
dotnet run --project src/src/StevesBot.Worker
|
||||||
```
|
|
||||||
|
|
||||||
#### Using Docker
|
# Run the YouTube webhook service (in separate terminal)
|
||||||
|
dotnet run --project src/src/StevesBot.Webhook
|
||||||
```powershell
|
|
||||||
# Build the Docker image
|
|
||||||
docker build -t steves-bot src/
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -d --name steves-bot steves-bot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
@@ -68,28 +89,58 @@ docker run -d --name steves-bot steves-bot
|
|||||||
|
|
||||||
```txt
|
```txt
|
||||||
src/
|
src/
|
||||||
├── StevesBot.Worker/ # Main bot application
|
├── src/
|
||||||
│ ├── Discord/ # Discord client implementation
|
│ ├── StevesBot.Library/ # Shared library components
|
||||||
│ │ ├── Gateway/ # WebSocket gateway client
|
│ │ ├── Discord/ # Common Discord REST client
|
||||||
│ │ ├── Rest/ # REST API client
|
│ │ └── Telemetry/ # Shared telemetry setup
|
||||||
│ │ └── Shared/ # Common Discord models
|
│ ├── StevesBot.Worker/ # Discord bot worker service
|
||||||
│ ├── Handlers/ # Event handlers
|
│ │ ├── Discord/ # Discord Gateway client implementation
|
||||||
│ ├── Telemetry/ # Observability setup
|
│ │ │ ├── Gateway/ # WebSocket gateway client
|
||||||
│ ├── Threading/ # Async utilities
|
│ │ │ ├── Rest/ # REST API client
|
||||||
│ └── WebSockets/ # WebSocket abstractions
|
│ │ │ └── Shared/ # Common Discord models
|
||||||
└── StevesBot.Worker.Tests/ # Comprehensive test suite
|
│ │ ├── 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
|
### Key Components
|
||||||
|
|
||||||
|
#### Discord Bot Worker
|
||||||
|
|
||||||
- **DiscordGatewayClient**: Custom WebSocket client for Discord Gateway API
|
- **DiscordGatewayClient**: Custom WebSocket client for Discord Gateway API
|
||||||
- **Worker**: Background service that manages the bot lifecycle
|
- **Worker**: Background service that manages the bot lifecycle
|
||||||
- **WebSocket Management**: Custom WebSocket factory and connection handling
|
- **WebSocket Management**: Custom WebSocket factory and connection handling
|
||||||
- **AsyncLock**: Thread-safe async locking mechanism
|
- **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
|
## 🔧 Configuration
|
||||||
|
|
||||||
### Discord Client Options
|
### Discord Worker Options
|
||||||
|
|
||||||
| Setting | Description | Required |
|
| Setting | Description | Required |
|
||||||
|----------------------------------|-----------------------------------------|----------|
|
|----------------------------------|-----------------------------------------|----------|
|
||||||
@@ -97,6 +148,17 @@ src/
|
|||||||
| `DiscordClientOptions__AppToken` | Bot token from Discord Developer Portal | Yes |
|
| `DiscordClientOptions__AppToken` | Bot token from Discord Developer Portal | Yes |
|
||||||
| `DiscordClientOptions__Intents` | Discord Gateway intents | 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
|
### Telemetry Options
|
||||||
|
|
||||||
| Setting | Description | Required |
|
| Setting | Description | Required |
|
||||||
@@ -112,9 +174,6 @@ The project includes a comprehensive test suite with both unit and integration t
|
|||||||
```powershell
|
```powershell
|
||||||
# Run all tests
|
# Run all tests
|
||||||
dotnet test src/StevesBot.sln
|
dotnet test src/StevesBot.sln
|
||||||
|
|
||||||
# Run with coverage
|
|
||||||
dotnet test src/StevesBot.sln --collect:"XPlat Code Coverage"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Coverage
|
### Test Coverage
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<RepoRoot>$(MSBuildThisFileDirectory)</RepoRoot>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<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
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
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
|
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
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -17,32 +29,88 @@ Global
|
|||||||
Release|x86 = Release|x86
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|x64.Build.0 = Debug|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Debug|x86.Build.0 = Debug|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|Any CPU.Build.0 = Release|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|x64.ActiveCfg = Release|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|x64.Build.0 = Release|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|x86.ActiveCfg = Release|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{C45EE62D-8222-46D7-B4FF-22B155B17561}.Release|x86.Build.0 = Release|Any CPU
|
{51B85887-7083-47A0-B83F-4E1561B14AA9}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|x64.Build.0 = Debug|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Debug|x86.Build.0 = Debug|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|Any CPU.Build.0 = Release|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|x64.ActiveCfg = Release|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|x64.Build.0 = Release|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|x86.ActiveCfg = Release|Any CPU
|
{7C243CA8-55A0-4D73-9FF6-F5E801B7146D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{FFB90DB8-1B9A-47E3-8252-746F1445CA02}.Release|x86.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
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
|
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
|
||||||
File renamed without changes.
@@ -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 ApiUrl { get; init; } = string.Empty;
|
||||||
public string AppToken { 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")]
|
[JsonPropertyName("id")]
|
||||||
public string Id { get; init; } = string.Empty;
|
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("type")] int Type,
|
||||||
[property: JsonPropertyName("message_id")] string MessageId,
|
[property: JsonPropertyName("message_id")] string MessageId,
|
||||||
[property: JsonPropertyName("channel_id")] string ChannelId,
|
[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 ILogger<DiscordRestClient> _logger;
|
||||||
private readonly HttpClient _httpClient;
|
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()
|
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<string> GetGatewayUrlAsync(CancellationToken cancellationToken = default);
|
||||||
Task<DiscordMessage> CreateMessageAsync(string channelId, CreateMessageRequest request, 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();
|
var seq = new SeqOptions();
|
||||||
builder.Configuration.GetSection(nameof(SeqOptions)).Bind(seq);
|
builder.Configuration.GetSection(nameof(SeqOptions)).Bind(seq);
|
||||||
@@ -14,14 +24,20 @@ internal static class Extensions
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(instrumentationFunc());
|
||||||
|
|
||||||
|
var instrumentation = builder.Services
|
||||||
|
.BuildServiceProvider()
|
||||||
|
.GetRequiredService<IInstrumentation>();
|
||||||
|
|
||||||
builder.Services.AddOpenTelemetry()
|
builder.Services.AddOpenTelemetry()
|
||||||
.ConfigureResource(resource =>
|
.ConfigureResource(resource =>
|
||||||
{
|
{
|
||||||
resource.AddService(Instrumentation.SourceName, Instrumentation.SourceVersion);
|
resource.AddService(instrumentation.SourceName, instrumentation.SourceVersion);
|
||||||
resource.AddAttributes(new Dictionary<string, object>
|
resource.AddAttributes(new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["service.name"] = Instrumentation.SourceName,
|
["service.name"] = instrumentation.SourceName,
|
||||||
["service.version"] = Instrumentation.SourceVersion,
|
["service.version"] = instrumentation.SourceVersion,
|
||||||
["service.instance.id"] = Environment.MachineName,
|
["service.instance.id"] = Environment.MachineName,
|
||||||
["service.namespace"] = "stevesbot",
|
["service.namespace"] = "stevesbot",
|
||||||
["service.environment"] = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "production",
|
["service.environment"] = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "production",
|
||||||
@@ -38,7 +54,7 @@ internal static class Extensions
|
|||||||
})
|
})
|
||||||
.WithTracing(tb =>
|
.WithTracing(tb =>
|
||||||
{
|
{
|
||||||
tb.AddSource(Instrumentation.SourceName);
|
tb.AddSource(instrumentation.SourceName);
|
||||||
tb.AddAspNetCoreInstrumentation();
|
tb.AddAspNetCoreInstrumentation();
|
||||||
tb.AddHttpClientInstrumentation();
|
tb.AddHttpClientInstrumentation();
|
||||||
tb.AddOtlpExporter(o =>
|
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
|
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
|
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)
|
public static IServiceCollection AddDiscordGatewayClient(this IServiceCollection services, Action<IDiscordGatewayClient>? configure = null)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IDiscordGatewayClient>(sp =>
|
services.AddSingleton<IDiscordGatewayClient>(sp =>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -11,7 +11,7 @@ builder.Services.AddSingleton(static sp =>
|
|||||||
return discordOptions;
|
return discordOptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.AddTelemetry();
|
builder.AddTelemetry(static () => new StevesBotWorkerInstrumentation());
|
||||||
|
|
||||||
builder.Services.AddSingleton<IWebSocketFactory, WebSocketFactory>();
|
builder.Services.AddSingleton<IWebSocketFactory, WebSocketFactory>();
|
||||||
builder.Services.AddSingleton(TimeProvider.System);
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
File renamed without changes.
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,3 @@
|
|||||||
global using System.Diagnostics;
|
|
||||||
global using System.Net.Http.Json;
|
|
||||||
global using System.Net.WebSockets;
|
global using System.Net.WebSockets;
|
||||||
global using System.Reflection;
|
global using System.Reflection;
|
||||||
global using System.Text;
|
global using System.Text;
|
||||||
@@ -8,20 +6,16 @@ global using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
global using Microsoft.Extensions.Options;
|
global using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
global using OpenTelemetry.Exporter;
|
global using StevesBot.Library.Discord;
|
||||||
global using OpenTelemetry.Logs;
|
global using StevesBot.Library.Discord.Common;
|
||||||
global using OpenTelemetry.Resources;
|
global using StevesBot.Library.Discord.Rest;
|
||||||
global using OpenTelemetry.Trace;
|
global using StevesBot.Library.Discord.Rest.Requests;
|
||||||
|
global using StevesBot.Library.Telemetry;
|
||||||
global using StevesBot.Worker;
|
global using StevesBot.Worker;
|
||||||
global using StevesBot.Worker.Discord;
|
global using StevesBot.Worker.Discord;
|
||||||
global using StevesBot.Worker.Discord.Gateway;
|
global using StevesBot.Worker.Discord.Gateway;
|
||||||
global using StevesBot.Worker.Discord.Gateway.Events;
|
global using StevesBot.Worker.Discord.Gateway.Events;
|
||||||
global using StevesBot.Worker.Discord.Gateway.Events.Data;
|
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.Handlers;
|
||||||
global using StevesBot.Worker.Telemetry;
|
global using StevesBot.Worker.Telemetry;
|
||||||
global using StevesBot.Worker.Threading;
|
global using StevesBot.Worker.Threading;
|
||||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user