Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dddf99288 | |||
| 1c75994722 | |||
| c0147fd310 | |||
| 745a65818d | |||
| 05ca8061e9 | |||
| ab59d234f3 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 2.1.1
|
||||
VERSION: 2.3.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
@@ -37,6 +37,20 @@ jobs:
|
||||
docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
|
||||
docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||
|
||||
- name: Build Discord Bot image
|
||||
run: |
|
||||
docker build \
|
||||
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
|
||||
-f src/GmRelay.DiscordBot/Dockerfile \
|
||||
-t git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest \
|
||||
-t git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }} \
|
||||
.
|
||||
|
||||
- name: Push Discord Bot image
|
||||
run: |
|
||||
docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:latest
|
||||
docker push git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
|
||||
|
||||
- name: Build Web image
|
||||
run: |
|
||||
docker build \
|
||||
@@ -68,6 +82,14 @@ jobs:
|
||||
--format table \
|
||||
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||
|
||||
- name: Scan Discord Bot image
|
||||
run: |
|
||||
trivy image \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
|
||||
|
||||
- name: Scan Web image
|
||||
run: |
|
||||
trivy image \
|
||||
@@ -88,6 +110,7 @@ jobs:
|
||||
run: |
|
||||
echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env
|
||||
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
|
||||
echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env
|
||||
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
|
||||
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
|
||||
|
||||
@@ -97,7 +120,7 @@ jobs:
|
||||
docker login git.codeanddice.ru/ -u toutsu -p ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
# Pull гарантирует, что мы получили нужную версию.
|
||||
docker compose pull bot web
|
||||
docker compose pull bot discord web
|
||||
|
||||
# Запускаем! Флаг -d оставит их работать в фоне.
|
||||
docker compose up -d
|
||||
|
||||
@@ -69,6 +69,9 @@ jobs:
|
||||
- name: Build Bot (compile check, includes SAST)
|
||||
run: dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
|
||||
|
||||
- name: Build Discord Bot (compile check, includes SAST)
|
||||
run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore
|
||||
|
||||
- name: Build Web (compile check, includes SAST)
|
||||
run: dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>2.1.1</Version>
|
||||
<Version>2.3.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/GmRelay.AppHost/GmRelay.AppHost.csproj" />
|
||||
<Project Path="src/GmRelay.Bot/GmRelay.Bot.csproj" />
|
||||
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
|
||||
<Project Path="src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj" />
|
||||
<Project Path="src/GmRelay.Web/GmRelay.Web.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v2.0.1`.
|
||||
**Текущая версия:** `v2.2.0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|---|---|
|
||||
| Язык | C# 14 (.NET 10) |
|
||||
| Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
|
||||
| Бот | Telegram.Bot, **Native AOT** |
|
||||
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker) |
|
||||
| Веб | Blazor Server |
|
||||
| Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
|
||||
| БД | PostgreSQL |
|
||||
@@ -74,6 +74,9 @@ cp .env.example .env
|
||||
# Токен от @BotFather (используется ботом и как секретный ключ веб-авторизации)
|
||||
TELEGRAM_BOT_TOKEN=ваш_токен_здесь
|
||||
|
||||
# Токен Discord application bot
|
||||
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
|
||||
|
||||
# Имя бота без @ (для Telegram Login Widget)
|
||||
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
|
||||
|
||||
@@ -98,6 +101,7 @@ docker compose up -d
|
||||
- создание Docker-сети и volume PostgreSQL;
|
||||
- подъём PostgreSQL (`db:5432`);
|
||||
- запуск бота с плавной миграцией (DbUp);
|
||||
- запуск отдельного Discord Gateway worker на NetCord;
|
||||
- запуск веб-приложения с подключением к БД и Telegram API.
|
||||
|
||||
### 3. Первоначальная настройка
|
||||
@@ -151,11 +155,10 @@ BACKUP_VOLUME_NAME=game_pgbackups
|
||||
├── src/
|
||||
│ ├── GmRelay.AppHost/ # .NET Aspire orchestrator
|
||||
│ ├── GmRelay.Bot/ # Telegram-бот (Native AOT)
|
||||
│ ├── GmRelay.Migrator/ # DbUp-миграции
|
||||
│ ├── GmRelay.DiscordBot/ # Discord Gateway worker на NetCord
|
||||
│ ├── GmRelay.ServiceDefaults/ # Aspire service defaults
|
||||
│ ├── GmRelay.Shared/ # Общие доменные модели
|
||||
│ ├── GmRelay.Web/ # Blazor Server dashboard
|
||||
│ └── GmRelay.Worker/ # Background workers
|
||||
│ └── GmRelay.Web/ # Blazor Server dashboard
|
||||
├── tests/
|
||||
│ └── GmRelay.Bot.Tests/ # xUnit + NSubstitute
|
||||
├── compose.yaml # Docker Compose (AMD64 + ARM64)
|
||||
|
||||
+14
-2
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.1.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.3.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -66,8 +66,20 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.3.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
|
||||
networks:
|
||||
- gmrelay
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.1.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.3.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
# Discord NetCord Gateway Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a separate `src/GmRelay.DiscordBot` worker that uses NetCord Gateway for Discord slash commands and component interactions while keeping Telegram dependencies isolated in `src/GmRelay.Bot`.
|
||||
|
||||
**Architecture:** Create a new .NET worker project that references `GmRelay.ServiceDefaults` and `GmRelay.Shared`, validates `Discord:Token` during startup, registers NetCord gateway/application command/component services, and logs gateway lifecycle events through NetCord gateway handlers. Keep database connectivity aligned with the existing worker by registering the same `ConnectionStrings:gmrelaydb` `NpgsqlDataSource` pattern, but do not move Telegram code or dependencies.
|
||||
|
||||
**Tech Stack:** .NET 10 worker, Aspire service defaults, NetCord.Hosting `1.0.0-alpha.489`, Npgsql `10.0.2`, xUnit, Docker Compose, Gitea Actions.
|
||||
|
||||
---
|
||||
|
||||
## Issue
|
||||
|
||||
- Gitea issue: `#26`, `feat: добавить src/GmRelay.DiscordBot на NetCord Gateway`
|
||||
- Labels: `type:feature`, `area:discord`, `area:infra`, `platform:discord`, `priority:p1`, `pending-approval`
|
||||
- Version bump: minor, `2.1.1` -> `2.2.0`
|
||||
- Branch: `feature/issue-26-discord-netcord-gateway`
|
||||
|
||||
## Sources Checked
|
||||
|
||||
- NetCord application commands guide: `https://netcord.dev/guides/services/application-commands/introduction.html`
|
||||
- NetCord intents guide: `https://netcord.dev/guides/events/intents.html`
|
||||
- NetCord gateway handler docs: `https://netcord.dev/docs/NetCord.Hosting.Gateway.html`
|
||||
- NuGet flat container for `NetCord.Hosting`: latest observed version `1.0.0-alpha.489`
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` - Discord worker project and package references.
|
||||
- Create: `src/GmRelay.DiscordBot/Program.cs` - host composition, token validation, database registration, NetCord service registration.
|
||||
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs` - strongly typed Discord token/options validation.
|
||||
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs` - Discord-local startup redaction without referencing the Telegram worker project.
|
||||
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` - NetCord gateway lifecycle handler for ready/connect/resume/disconnect/close/rate-limit events where available.
|
||||
- Create: `src/GmRelay.DiscordBot/Dockerfile` - publish and runtime image for the Discord worker.
|
||||
- Modify: `GM-Relay.slnx` - include the new project.
|
||||
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj` - reference the Discord worker for Aspire orchestration.
|
||||
- Modify: `src/GmRelay.AppHost/Program.cs` - add `discord` project with PostgreSQL reference.
|
||||
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` - reference the Discord worker project.
|
||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs` - source-level tests for solution inclusion, Docker/Compose/CI wiring, and Telegram isolation.
|
||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs` - unit tests for token validation.
|
||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs` - source-level startup tests for NetCord registration, service defaults, and PostgreSQL connection requirements.
|
||||
- Modify: `compose.yaml` - add `discord` service and versioned image tag.
|
||||
- Modify: `.gitea/workflows/deploy.yml` - build/push/scan/pull Discord image and include `DISCORD_BOT_TOKEN` in `.env`.
|
||||
- Modify: `.gitea/workflows/pr-checks.yml` - build the Discord project in PR checks.
|
||||
- Modify: `Directory.Build.props` - version `2.2.0`.
|
||||
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - visible version `v2.2.0`.
|
||||
- Generated by restore: `src/GmRelay.DiscordBot/packages.lock.json`.
|
||||
- Generated by restore: updates to `tests/GmRelay.Bot.Tests/packages.lock.json` and `src/GmRelay.AppHost/packages.lock.json`.
|
||||
|
||||
## TDD Plan
|
||||
|
||||
### Task 1: Project Presence And Telegram Isolation
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
|
||||
- Modify: `GM-Relay.slnx`
|
||||
- Create: `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`
|
||||
- Create: `src/GmRelay.DiscordBot/Program.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordProjectStructureTests
|
||||
{
|
||||
private static string GetRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Solution_ShouldIncludeDiscordWorkerProject()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var solution = File.ReadAllText(Path.Combine(repoRoot, "GM-Relay.slnx"));
|
||||
|
||||
Assert.Contains("src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", solution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscordWorkerProject_ShouldExistWithoutTelegramDependency()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var projectPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "GmRelay.DiscordBot.csproj");
|
||||
|
||||
Assert.True(File.Exists(projectPath), "Discord worker project should exist.");
|
||||
|
||||
var project = File.ReadAllText(projectPath);
|
||||
Assert.Contains("Microsoft.NET.Sdk.Worker", project);
|
||||
Assert.Contains("NetCord.Hosting", project);
|
||||
Assert.Contains("GmRelay.ServiceDefaults.csproj", project);
|
||||
Assert.Contains("GmRelay.Shared.csproj", project);
|
||||
Assert.DoesNotContain("Telegram.Bot", project);
|
||||
Assert.DoesNotContain("GmRelay.Bot.csproj", project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramWorkerProject_ShouldNotReferenceNetCord()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var project = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Bot", "GmRelay.Bot.csproj"));
|
||||
|
||||
Assert.DoesNotContain("NetCord", project, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
|
||||
|
||||
Expected: FAIL because `GM-Relay.slnx` does not include `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` and the project file does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj`:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GmRelay.ServiceDefaults\GmRelay.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\GmRelay.Shared\GmRelay.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
Add this project to `GM-Relay.slnx` inside `/src/`:
|
||||
|
||||
```xml
|
||||
<Project Path="src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj" />
|
||||
```
|
||||
|
||||
Create temporary minimal `src/GmRelay.DiscordBot/Program.cs`:
|
||||
|
||||
```csharp
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.AddServiceDefaults();
|
||||
await builder.Build().RunAsync();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordProjectStructureTests`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Token Validation
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
|
||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`
|
||||
- Create: `src/GmRelay.DiscordBot/DiscordOptions.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add the project reference to `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`:
|
||||
|
||||
```xml
|
||||
<ProjectReference Include="..\..\src\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
|
||||
```
|
||||
|
||||
Create `tests/GmRelay.Bot.Tests/Discord/DiscordOptionsTests.cs`:
|
||||
|
||||
```csharp
|
||||
using GmRelay.DiscordBot;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordOptionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_ShouldRejectMissingToken(string? token)
|
||||
{
|
||||
var options = new DiscordOptions { Token = token };
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
|
||||
Assert.Contains("Discord:Token is required", exception.Message);
|
||||
Assert.Contains("Discord__Token", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldAcceptConfiguredToken()
|
||||
{
|
||||
var options = new DiscordOptions { Token = "configured-token" };
|
||||
|
||||
options.Validate();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
|
||||
|
||||
Expected: FAIL at compile time because `GmRelay.DiscordBot.DiscordOptions` is not defined.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `src/GmRelay.DiscordBot/DiscordOptions.cs`:
|
||||
|
||||
```csharp
|
||||
namespace GmRelay.DiscordBot;
|
||||
|
||||
public sealed class DiscordOptions
|
||||
{
|
||||
public string? Token { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Token))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Discord:Token is required. Set via environment variable Discord__Token or user secrets.");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordOptionsTests`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Startup Wiring For Service Defaults, PostgreSQL, NetCord, And Slash Commands
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
|
||||
- Modify: `src/GmRelay.DiscordBot/Program.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordStartupTests
|
||||
{
|
||||
private static string GetRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldValidateDiscordTokenBeforeRunning()
|
||||
{
|
||||
var program = ReadProgram();
|
||||
|
||||
Assert.Contains("GetRequiredSection(\"Discord\")", program);
|
||||
Assert.Contains("DiscordOptions", program);
|
||||
Assert.Contains(".Validate()", program);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldRegisterServiceDefaultsAndPostgresDataSource()
|
||||
{
|
||||
var program = ReadProgram();
|
||||
|
||||
Assert.Contains("builder.AddServiceDefaults()", program);
|
||||
Assert.Contains("ConnectionStrings:gmrelaydb is required", program);
|
||||
Assert.Contains("NpgsqlDataSource", program);
|
||||
Assert.Contains("SecretRedactor.RedactConnectionString", program);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldRegisterNetCordGatewayApplicationCommandsAndComponents()
|
||||
{
|
||||
var program = ReadProgram();
|
||||
|
||||
Assert.Contains(".AddDiscordGateway", program);
|
||||
Assert.Contains(".AddApplicationCommands", program);
|
||||
Assert.Contains(".AddComponentInteractions", program);
|
||||
Assert.Contains(".AddGatewayHandlers", program);
|
||||
Assert.Contains("AddSlashCommand", program);
|
||||
}
|
||||
|
||||
private static string ReadProgram()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
return File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Program.cs"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
|
||||
|
||||
Expected: FAIL because `Program.cs` does not validate `Discord:Token`, register `NpgsqlDataSource`, or register NetCord services yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Replace `src/GmRelay.DiscordBot/Program.cs` with host composition that:
|
||||
|
||||
```csharp
|
||||
using GmRelay.DiscordBot;
|
||||
using GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
using NetCord.Gateway;
|
||||
using NetCord.Hosting.Gateway;
|
||||
using NetCord.Hosting.Services.ApplicationCommands;
|
||||
using NetCord.Hosting.Services.ComponentInteractions;
|
||||
using Npgsql;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
var discordOptions = builder.Configuration
|
||||
.GetRequiredSection("Discord")
|
||||
.Get<DiscordOptions>() ?? new DiscordOptions();
|
||||
discordOptions.Validate();
|
||||
|
||||
builder.Services.AddSingleton(discordOptions);
|
||||
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var connectionString = config.GetConnectionString("gmrelaydb")
|
||||
?? throw new InvalidOperationException(
|
||||
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
|
||||
|
||||
var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup");
|
||||
logger.LogInformation(
|
||||
"Configured PostgreSQL data source with connection string {ConnectionString}",
|
||||
SecretRedactor.RedactConnectionString(connectionString));
|
||||
|
||||
return NpgsqlDataSource.Create(connectionString);
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddDiscordGateway(options =>
|
||||
{
|
||||
options.Token = discordOptions.Token;
|
||||
options.Intents = GatewayIntents.Guilds;
|
||||
})
|
||||
.AddApplicationCommands()
|
||||
.AddComponentInteractions()
|
||||
.AddGatewayHandlers(typeof(Program).Assembly);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
|
||||
|
||||
await host.RunAsync();
|
||||
```
|
||||
|
||||
Use the Discord-local `SecretRedactor` namespace instead of `GmRelay.Bot.Infrastructure.Logging` so the new project does not reference the Telegram worker.
|
||||
|
||||
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/SecretRedactor.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
|
||||
internal static partial class SecretRedactor
|
||||
{
|
||||
public static string RedactConnectionString(string connectionString)
|
||||
{
|
||||
return PasswordPattern().Replace(connectionString, "$1***");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")]
|
||||
private static partial Regex PasswordPattern();
|
||||
}
|
||||
```
|
||||
|
||||
If `GatewayClientOptions.Token` does not accept `string`, adjust to NetCord's required token type after compile feedback while preserving the tests' intent.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~DiscordStartupTests`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Gateway Lifecycle Logging
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordStartupTests.cs`
|
||||
- Create: `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `DiscordStartupTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var loggerPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Infrastructure", "Logging", "DiscordGatewayLifecycleLogger.cs");
|
||||
|
||||
Assert.True(File.Exists(loggerPath), "Discord gateway lifecycle logger should exist.");
|
||||
|
||||
var logger = File.ReadAllText(loggerPath);
|
||||
Assert.Contains("IReadyGatewayHandler", logger);
|
||||
Assert.Contains("IDisconnectGatewayHandler", logger);
|
||||
Assert.Contains("IResumeGatewayHandler", logger);
|
||||
Assert.Contains("LogInformation", logger);
|
||||
Assert.DoesNotContain("Token", logger);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
|
||||
|
||||
Expected: FAIL because `DiscordGatewayLifecycleLogger.cs` does not exist.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `src/GmRelay.DiscordBot/Infrastructure/Logging/DiscordGatewayLifecycleLogger.cs` using the concrete NetCord handler signatures from the installed `NetCord.Hosting` package. Minimum behavior:
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetCord.Gateway;
|
||||
using NetCord.Hosting.Gateway;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
|
||||
public sealed class DiscordGatewayLifecycleLogger(
|
||||
ILogger<DiscordGatewayLifecycleLogger> logger)
|
||||
: IReadyGatewayHandler,
|
||||
IDisconnectGatewayHandler,
|
||||
IResumeGatewayHandler
|
||||
{
|
||||
public ValueTask HandleAsync(ReadyEventArgs arg)
|
||||
{
|
||||
logger.LogInformation("Discord gateway ready as application {ApplicationId}", arg.Application.Id);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask HandleAsync(DisconnectEventArgs arg)
|
||||
{
|
||||
logger.LogWarning("Discord gateway disconnected with close status {CloseStatus}", arg.CloseStatus);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask HandleAsync()
|
||||
{
|
||||
logger.LogInformation("Discord gateway session resumed");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If interface signatures differ in `1.0.0-alpha.489`, inspect the package XML/docs and adjust the handlers to compile while keeping ready/disconnect/resume logging and never logging token values.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 5: Runtime Container, Compose, AppHost, And CI Wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
|
||||
- Create: `src/GmRelay.DiscordBot/Dockerfile`
|
||||
- Modify: `compose.yaml`
|
||||
- Modify: `src/GmRelay.AppHost/GmRelay.AppHost.csproj`
|
||||
- Modify: `src/GmRelay.AppHost/Program.cs`
|
||||
- Modify: `.gitea/workflows/pr-checks.yml`
|
||||
- Modify: `.gitea/workflows/deploy.yml`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `DiscordProjectStructureTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"));
|
||||
var appHostProject = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "GmRelay.AppHost.csproj"));
|
||||
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
|
||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||
|
||||
Assert.Contains("gmrelay-discord-bot:2.2.0", compose);
|
||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||
Assert.Contains("dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore", prChecks);
|
||||
Assert.Contains("GmRelay.DiscordBot.csproj", appHostProject);
|
||||
Assert.Contains("Projects.GmRelay_DiscordBot", appHostProgram);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
|
||||
|
||||
Expected: FAIL because runtime wiring is not present.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `src/GmRelay.DiscordBot/Dockerfile` modeled after `src/GmRelay.Bot/Dockerfile`, with project copy/restore for `GmRelay.DiscordBot`, `GmRelay.ServiceDefaults`, and `GmRelay.Shared`, and entrypoint `./GmRelay.DiscordBot`.
|
||||
|
||||
Add `discord` service to `compose.yaml`:
|
||||
|
||||
```yaml
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.2.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
|
||||
networks:
|
||||
- gmrelay
|
||||
```
|
||||
|
||||
Add Discord project reference to `src/GmRelay.AppHost/GmRelay.AppHost.csproj`:
|
||||
|
||||
```xml
|
||||
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
|
||||
```
|
||||
|
||||
Add Discord service to `src/GmRelay.AppHost/Program.cs`:
|
||||
|
||||
```csharp
|
||||
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
```
|
||||
|
||||
Update `.gitea/workflows/pr-checks.yml` with:
|
||||
|
||||
```yaml
|
||||
- name: Build Discord Bot (compile check, includes SAST)
|
||||
run: dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore
|
||||
```
|
||||
|
||||
Update `.gitea/workflows/deploy.yml` to build, push, scan, pull, and deploy `git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}` and write `DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}` to `.env`.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 6: Version Synchronization
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs`
|
||||
- Modify: `Directory.Build.props`
|
||||
- Modify: `compose.yaml`
|
||||
- Modify: `.gitea/workflows/deploy.yml`
|
||||
- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `DiscordProjectStructureTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
|
||||
Assert.Contains("<Version>2.2.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 2.2.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:2.2.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("v2.2.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
|
||||
|
||||
Expected: FAIL because current version is `2.1.1`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Update:
|
||||
- `Directory.Build.props`: `<Version>2.2.0</Version>`
|
||||
- `.gitea/workflows/deploy.yml`: `VERSION: 2.2.0`
|
||||
- `compose.yaml`: `gmrelay-bot:2.2.0`, `gmrelay-web:2.2.0`, `gmrelay-discord-bot:2.2.0`
|
||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`: `v2.2.0`
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter Version_ShouldBeSynchronizedForDiscordFeatureRelease`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 7: Restore, Format, Build, And Full Test Verification
|
||||
|
||||
**Files:**
|
||||
- Generated/updated: `src/GmRelay.DiscordBot/packages.lock.json`
|
||||
- Generated/updated: `tests/GmRelay.Bot.Tests/packages.lock.json`
|
||||
- Generated/updated: `src/GmRelay.AppHost/packages.lock.json`
|
||||
- Any code formatting changes required by `dotnet format`
|
||||
|
||||
- [ ] **Step 1: Restore lock files**
|
||||
|
||||
Run: `dotnet restore GM-Relay.slnx`
|
||||
|
||||
Expected: restore succeeds and creates/updates lock files for the new project references and NetCord dependency.
|
||||
|
||||
- [ ] **Step 2: Run targeted tests**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter FullyQualifiedName~Discord`
|
||||
|
||||
Expected: all Discord tests pass.
|
||||
|
||||
- [ ] **Step 3: Run full tests**
|
||||
|
||||
Run: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 4: Run release build**
|
||||
|
||||
Run: `dotnet build GM-Relay.slnx -c Release`
|
||||
|
||||
Expected: solution build succeeds and includes `src/GmRelay.DiscordBot`.
|
||||
|
||||
- [ ] **Step 5: Run format check**
|
||||
|
||||
Run: `dotnet format --verify-no-changes --verbosity diagnostic`
|
||||
|
||||
Expected: no formatting changes required.
|
||||
|
||||
- [ ] **Step 6: Inspect diff for secrets**
|
||||
|
||||
Run: `git diff --check`
|
||||
|
||||
Expected: no whitespace errors and no Discord token value in tracked files.
|
||||
|
||||
Run: `git diff -- . ':!*.lock.json'`
|
||||
|
||||
Expected: diff contains configuration variable names such as `Discord__Token` and `DISCORD_BOT_TOKEN`, but not a real token value.
|
||||
|
||||
### Task 8: Commit, PR, CI, Deploy, Release, Issue Closure
|
||||
|
||||
**Files:**
|
||||
- All intended implementation, test, lock, workflow, compose, and version files.
|
||||
|
||||
- [ ] **Step 1: Create commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git add GM-Relay.slnx Directory.Build.props compose.yaml .gitea/workflows/deploy.yml .gitea/workflows/pr-checks.yml src/GmRelay.AppHost src/GmRelay.DiscordBot src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests
|
||||
git commit -m "feat: add Discord NetCord gateway worker"
|
||||
```
|
||||
|
||||
Expected: only intended files are staged and committed. Do not stage untracked `CLAUDE.md`.
|
||||
|
||||
- [ ] **Step 2: Push branch and open PR**
|
||||
|
||||
Run: `git push -u origin feature/issue-26-discord-netcord-gateway`
|
||||
|
||||
Create Gitea PR to `main` with:
|
||||
- Summary of Discord worker, token validation, runtime wiring, and version bump.
|
||||
- Test plan showing targeted Discord tests, full tests, release build, format, and secret diff inspection.
|
||||
- Link to issue `#26`.
|
||||
|
||||
- [ ] **Step 3: Store Discord token as a Gitea Actions secret**
|
||||
|
||||
Use Gitea Actions configuration to create or update repository secret `DISCORD_BOT_TOKEN` with the user-provided Discord bot token.
|
||||
|
||||
Expected: token is stored only as an Actions secret. The token value is not written to source files, plan files, logs, PR text, release notes, or commits.
|
||||
|
||||
- [ ] **Step 4: Monitor CI**
|
||||
|
||||
Use Gitea Actions run reads until PR checks finish. If CI fails, inspect logs, fix with TDD where the failure is code behavior, push again, and re-check.
|
||||
|
||||
- [ ] **Step 5: Review, merge, deploy, release**
|
||||
|
||||
After CI passes and review is approved:
|
||||
- Merge PR.
|
||||
- Monitor deploy workflow on `main`.
|
||||
- Create release `v2.2.0` with Russian release notes.
|
||||
- Close issue `#26` with a comment linking PR and release.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: Project creation, NetCord Gateway, slash/component service registration, `Discord__Token`, PostgreSQL service defaults, lifecycle logging, Telegram isolation, solution build, compose/deploy integration, and version sync are covered.
|
||||
- Placeholder scan: No task uses `TBD`, `TODO`, or an unspecified "add tests" instruction.
|
||||
- Type consistency: Test class names and file paths are consistent across tasks; NetCord lifecycle handler signatures are explicitly marked for compile-driven adjustment because the package is prerelease and must be verified against installed `1.0.0-alpha.489`.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GmRelay.Bot\GmRelay.Bot.csproj" />
|
||||
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
|
||||
<ProjectReference Include="..\GmRelay.Web\GmRelay.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
|
||||
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
|
||||
builder.AddProject<Projects.GmRelay_Web>("web")
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace GmRelay.DiscordBot;
|
||||
|
||||
public sealed class DiscordOptions
|
||||
{
|
||||
public string? Token { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Token))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Discord:Token is required. Set via environment variable Discord__Token or user secrets.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Stage 1: Build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY ["src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", "src/GmRelay.DiscordBot/"]
|
||||
COPY ["src/GmRelay.ServiceDefaults/GmRelay.ServiceDefaults.csproj", "src/GmRelay.ServiceDefaults/"]
|
||||
COPY ["src/GmRelay.Shared/GmRelay.Shared.csproj", "src/GmRelay.Shared/"]
|
||||
|
||||
RUN dotnet restore "src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj"
|
||||
|
||||
COPY src/ src/
|
||||
WORKDIR /src/src/GmRelay.DiscordBot
|
||||
RUN dotnet publish "GmRelay.DiscordBot.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
USER $APP_UID
|
||||
ENTRYPOINT ["dotnet", "GmRelay.DiscordBot.dll"]
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
|
||||
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GmRelay.ServiceDefaults\GmRelay.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\GmRelay.Shared\GmRelay.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using NetCord.Gateway;
|
||||
using NetCord.Hosting.Gateway;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
|
||||
public sealed class DiscordGatewayLifecycleLogger(
|
||||
ILogger<DiscordGatewayLifecycleLogger> logger)
|
||||
: IConnectGatewayHandler,
|
||||
IReadyGatewayHandler,
|
||||
IDisconnectGatewayHandler,
|
||||
IResumeGatewayHandler
|
||||
{
|
||||
public ValueTask HandleAsync()
|
||||
{
|
||||
logger.LogInformation("Discord gateway connected");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask HandleAsync(ReadyEventArgs arg)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Discord gateway ready for application {ApplicationId} in {GuildCount} guilds",
|
||||
arg.ApplicationId,
|
||||
arg.GuildIds.Count);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask HandleAsync(DisconnectEventArgs arg)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Discord gateway disconnected; reconnect scheduled: {Reconnect}",
|
||||
arg.Reconnect);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
ValueTask IResumeGatewayHandler.HandleAsync()
|
||||
{
|
||||
logger.LogInformation("Discord gateway session resumed");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
|
||||
internal static partial class SecretRedactor
|
||||
{
|
||||
public static string RedactConnectionString(string connectionString)
|
||||
{
|
||||
return PasswordPattern().Replace(connectionString, "$1***");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?i)(Password\s*=\s*)[^;]+")]
|
||||
private static partial Regex PasswordPattern();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using GmRelay.DiscordBot;
|
||||
using GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NetCord;
|
||||
using NetCord.Gateway;
|
||||
using NetCord.Hosting.Gateway;
|
||||
using NetCord.Hosting.Services.ApplicationCommands;
|
||||
using NetCord.Hosting.Services.ComponentInteractions;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
using Npgsql;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
var discordOptions = builder.Configuration
|
||||
.GetRequiredSection("Discord")
|
||||
.Get<DiscordOptions>() ?? new DiscordOptions();
|
||||
discordOptions.Validate();
|
||||
|
||||
builder.Services.AddSingleton(discordOptions);
|
||||
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var connectionString = config.GetConnectionString("gmrelaydb")
|
||||
?? throw new InvalidOperationException(
|
||||
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
|
||||
|
||||
var logger = loggerFactory.CreateLogger("GmRelay.DiscordBot.Startup");
|
||||
logger.LogInformation(
|
||||
"Configured PostgreSQL data source with connection string {ConnectionString}",
|
||||
SecretRedactor.RedactConnectionString(connectionString));
|
||||
|
||||
return NpgsqlDataSource.Create(connectionString);
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddDiscordGateway(options =>
|
||||
{
|
||||
options.Token = discordOptions.Token;
|
||||
options.Intents = GatewayIntents.Guilds;
|
||||
})
|
||||
.AddApplicationCommands()
|
||||
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
|
||||
.AddGatewayHandlers(typeof(Program).Assembly);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
|
||||
|
||||
await host.RunAsync();
|
||||
@@ -0,0 +1,125 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
|
||||
namespace GmRelay.DiscordBot.Rendering;
|
||||
|
||||
public static class DiscordSessionBatchRenderer
|
||||
{
|
||||
public static (IReadOnlyList<EmbedProperties> Embeds, IReadOnlyList<ActionRowProperties> ActionRows) Render(SessionBatchViewModel view)
|
||||
{
|
||||
var embeds = new List<EmbedProperties>();
|
||||
var actionRows = new List<ActionRowProperties>();
|
||||
|
||||
foreach (var session in view.Sessions)
|
||||
{
|
||||
var embed = BuildEmbed(view.Title, session);
|
||||
embeds.Add(embed);
|
||||
|
||||
if (session.AvailableActions.Count > 0)
|
||||
{
|
||||
var actionRow = new ActionRowProperties();
|
||||
foreach (var action in session.AvailableActions)
|
||||
{
|
||||
actionRow.Add(new ButtonProperties(
|
||||
$"{action.ActionKey}:{action.SessionId}",
|
||||
action.Label,
|
||||
ButtonStyle.Primary));
|
||||
}
|
||||
actionRows.Add(actionRow);
|
||||
}
|
||||
}
|
||||
|
||||
return (embeds, actionRows);
|
||||
}
|
||||
|
||||
private static EmbedProperties BuildEmbed(string title, SessionViewItem session)
|
||||
{
|
||||
var embed = new EmbedProperties()
|
||||
.WithTitle($"{title} — {session.ScheduledAt.FormatMoscow()}");
|
||||
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
embed = embed.WithDescription("❌ Сессия отменена");
|
||||
}
|
||||
else
|
||||
{
|
||||
embed = embed.WithDescription(BuildPlayerDescription(session));
|
||||
}
|
||||
|
||||
var fields = new List<EmbedFieldProperties>
|
||||
{
|
||||
new EmbedFieldProperties()
|
||||
.WithName("👥 Заполненность")
|
||||
.WithValue(session.MaxPlayers.HasValue
|
||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||
: $"{session.ActivePlayerCount}")
|
||||
.WithInline(),
|
||||
|
||||
new EmbedFieldProperties()
|
||||
.WithName("⏳ Лист ожидания")
|
||||
.WithValue(session.WaitlistedPlayers.Count > 0
|
||||
? session.WaitlistedPlayers.Count.ToString()
|
||||
: "—")
|
||||
.WithInline(),
|
||||
|
||||
new EmbedFieldProperties()
|
||||
.WithName("📊 Статус")
|
||||
.WithValue(FormatStatus(session.Status))
|
||||
.WithInline()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||
{
|
||||
embed = embed.WithUrl(session.JoinLink);
|
||||
}
|
||||
|
||||
embed = embed.WithColor(GetColor(session));
|
||||
embed = embed.AddFields(fields);
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
private static string BuildPlayerDescription(SessionViewItem session)
|
||||
{
|
||||
if (session.ActivePlayers.Count == 0)
|
||||
return "👥 Пока никто не записался";
|
||||
|
||||
var lines = session.ActivePlayers
|
||||
.Select(p => $"• {p.DisplayName}")
|
||||
.ToList();
|
||||
|
||||
if (session.WaitlistedPlayers.Count > 0)
|
||||
{
|
||||
lines.Add("");
|
||||
lines.Add($"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):");
|
||||
lines.AddRange(session.WaitlistedPlayers.Select(p => $"• {p.DisplayName}"));
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static string FormatStatus(string status) => status switch
|
||||
{
|
||||
SessionStatus.Planned => "Запланирована",
|
||||
SessionStatus.ConfirmationSent => "Ожидает подтверждения",
|
||||
SessionStatus.Confirmed => "Подтверждена",
|
||||
SessionStatus.Cancelled => "Отменена",
|
||||
_ => status
|
||||
};
|
||||
|
||||
private static Color GetColor(SessionViewItem session)
|
||||
{
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
return new Color(0xED4245);
|
||||
|
||||
if (session.Status == SessionStatus.Confirmed)
|
||||
return new Color(0x5865F2);
|
||||
|
||||
if (session.MaxPlayers.HasValue && session.ActivePlayerCount >= session.MaxPlayers.Value)
|
||||
return new Color(0xFEE75C);
|
||||
|
||||
return new Color(0x57F287);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Aspire.Npgsql": {
|
||||
"type": "Direct",
|
||||
"requested": "[13.2.2, )",
|
||||
"resolved": "13.2.2",
|
||||
"contentHash": "nEYgziWN7hksgEQEWy24JypcMCU8gKYcIIyPL05JfdXxUWuPRLotH/KOeuHevAjSEOYkL3dtGakBkJAuPobGmA==",
|
||||
"dependencies": {
|
||||
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5",
|
||||
"Npgsql.DependencyInjection": "10.0.1",
|
||||
"Npgsql.OpenTelemetry": "10.0.1",
|
||||
"OpenTelemetry.Extensions.Hosting": "1.15.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Hosting": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.CommandLine": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Json": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.UserSecrets": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Diagnostics": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Console": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Debug": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.EventLog": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.EventSource": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5"
|
||||
}
|
||||
},
|
||||
"NetCord.Hosting": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.0.0-alpha.489, )",
|
||||
"resolved": "1.0.0-alpha.489",
|
||||
"contentHash": "yQcvgY3uu98ndoLXpiFhJ5kungoWVLd7xnO18GmukRPVsRzyOKgxe/Ycp8DLYTtiQG9Wyg1pV4Iv6rvo+zck4w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.8",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8",
|
||||
"Microsoft.Extensions.Options.DataAnnotations": "10.0.8",
|
||||
"NetCord": "1.0.0-alpha.489"
|
||||
}
|
||||
},
|
||||
"NetCord.Hosting.Services": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.0.0-alpha.489, )",
|
||||
"resolved": "1.0.0-alpha.489",
|
||||
"contentHash": "Md46+zLB9UWYLM7PVlATytkjAC9602wBNKO7m5eaBiDdEvZOPsUrR6NJJr2YtJoKjttbvhte5ayDXj8WGGsevQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.8",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8",
|
||||
"Microsoft.Extensions.Options.DataAnnotations": "10.0.8",
|
||||
"NetCord.Hosting": "1.0.0-alpha.489",
|
||||
"NetCord.Services": "1.0.0-alpha.489"
|
||||
}
|
||||
},
|
||||
"Npgsql": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.2, )",
|
||||
"resolved": "10.0.2",
|
||||
"contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
|
||||
}
|
||||
},
|
||||
"SecurityCodeScan.VS2019": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.7, )",
|
||||
"resolved": "5.6.7",
|
||||
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
|
||||
},
|
||||
"AspNetCore.HealthChecks.NpgSql": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
|
||||
"Npgsql": "8.0.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.AmbientMetadata.Application": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "CNrEjaOCZ8d1HtB0mvpiX4EWxLkee2xy+CsYXxmsEYJSFgw3OmF9pIhP/tCTeYBHhpsKJj5wM63G8IBFGxAcsw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.2",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2",
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Compliance.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "1a4xDAT6fRyP8t419q3WvWMmMslDTvI7OAZLWBhn5rysFG0bl5xFenTswd1xAbT/3u3mx4Xyb5bPx+V+18tJeQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
|
||||
"Microsoft.Extensions.ObjectPool": "10.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Primitives": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Binder": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.8",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.CommandLine": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.FileExtensions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Physical": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Json": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.FileExtensions": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.UserSecrets": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Json": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Physical": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.AutoActivation": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "Z/OI261l7LnxyODKPx0trQyIHFyicCR/akfn64lGOjPcf4FpAZ7ePAGl2HPvQBUBSNfPTF0gWeCfuFmyftMgYA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Options": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "3qMK1D40D10kb5TdBtFJpzz6/WH0NinWs68ZZS8jCFgHMXDiOjGiPOneMmIocCP/wnUUW4Hzf8lMsIE1xIGxDA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics.HealthChecks": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw=="
|
||||
},
|
||||
"Microsoft.Extensions.Features": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.2",
|
||||
"contentHash": "X7tm2aV2w3lN9roSSGhl19lz4w76HvdiuKNhIv2XOiorYII9XCm66o/z9IJ0+QwkgvEv5gMZDM6rV6uwABHEQQ=="
|
||||
},
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.FileProviders.Physical": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.FileSystemGlobbing": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw=="
|
||||
},
|
||||
"Microsoft.Extensions.Hosting.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Http": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.2",
|
||||
"contentHash": "egUPC0xydb1ugCMcRyJ6zaOGOzx7N4coOVlGeLcIsXhUf1xHHwZeX+ob7JuG0dXExFduHYE/t+4/4y8BLlBKmw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
|
||||
"Microsoft.Extensions.Diagnostics": "10.0.2",
|
||||
"Microsoft.Extensions.Logging": "10.0.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
|
||||
"Microsoft.Extensions.Options": "10.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Http.Diagnostics": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "I0FBgF6yZRwYH9E3KQ2vHm80YZ7YBj+52GDsmOWXPBv/p15b/wUoNupV9kw3LnSNVsWMqlGbiuZgBnHpMwPh+Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Http": "10.0.2",
|
||||
"Microsoft.Extensions.Telemetry": "10.2.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Http.Resilience": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "Lg+OjBW+ODDbM4Ax4LoERvQ1dqSZ8I2gQc2+B0/WOWl2+PunLJ3xb3x8MtHGfcb/Mp98RoMpwRKm6Aj9mzXwrA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Http.Diagnostics": "10.2.0",
|
||||
"Microsoft.Extensions.ObjectPool": "10.0.2",
|
||||
"Microsoft.Extensions.Resilience": "10.2.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Configuration": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5",
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Console": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Debug": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5",
|
||||
"System.Diagnostics.EventLog": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.EventSource": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.ObjectPool": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.2",
|
||||
"contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ=="
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Primitives": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.8",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Options": "10.0.8",
|
||||
"Microsoft.Extensions.Primitives": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options.DataAnnotations": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "HhxwIGECGGJ8ox2kvm6/hkN/w1ZyKrO5uu/rLAL51V0ypPdahoNf+dHS6Er/DJs2aeUmH38ZTTzACfLy1O6w3Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8",
|
||||
"Microsoft.Extensions.Options": "10.0.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ=="
|
||||
},
|
||||
"Microsoft.Extensions.Resilience": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "v4WOdAOFxB3AcsUkZWNcHL3mYzs4KAPtHO8rkoQlFKOBoD3KyjjAL+h3tRwSK5i4UpF/yhxsQRY0JxKj4osxxw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Diagnostics": "10.0.2",
|
||||
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.2.0",
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.2",
|
||||
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0",
|
||||
"Polly.Extensions": "8.4.2",
|
||||
"Polly.RateLimiting": "8.4.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.ServiceDiscovery": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "AHTPfiKodj66xA8RwRkFD4q11V2AvzcuDsujv6ViPkOPtvBEYcPVplHakK56pPzWlX08MDS+TAQXfFXAeP7J5w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Http": "10.0.2",
|
||||
"Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.2.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.ServiceDiscovery.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "sANlOvfqfw/yfych4CLlHSKSWzIie6mQG7w83gVur1foNOafyHxcgpoQMvBf+KiB4Tpls6P1/Z77IIQSK8hxFg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.2",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
|
||||
"Microsoft.Extensions.Features": "10.0.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
|
||||
"Microsoft.Extensions.Options": "10.0.2",
|
||||
"Microsoft.Extensions.Primitives": "10.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Telemetry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "ssW5gosYlewNH/ISTyaLD/XfJT4GSjwShOUKv61fpXrqVmHkhuIA/5bBAGStM1XbzJjt9IG2vzfdHTu4zlX9Ew==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.AmbientMetadata.Application": "10.2.0",
|
||||
"Microsoft.Extensions.DependencyInjection.AutoActivation": "10.2.0",
|
||||
"Microsoft.Extensions.Logging.Configuration": "10.0.2",
|
||||
"Microsoft.Extensions.ObjectPool": "10.0.2",
|
||||
"Microsoft.Extensions.Telemetry.Abstractions": "10.2.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Telemetry.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.2.0",
|
||||
"contentHash": "6V4V6NX6RLUYWwV89DeW/4zK5xOycYHWhsfMXSpKVGgMHfXcczmbk6hBeqTnRPzhpATYcOWlmA6hk1jgdxUugA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Compliance.Abstractions": "10.2.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
|
||||
"Microsoft.Extensions.ObjectPool": "10.0.2",
|
||||
"Microsoft.Extensions.Options": "10.0.2"
|
||||
}
|
||||
},
|
||||
"NetCord": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.0-alpha.489",
|
||||
"contentHash": "/rM73l1pwwJCWHi7YrIiSVc+GVL0lV+k+amqNJUMINjLO+c5bKWj9PoNNoMhiPZoaORO4k6Uxp8EQfoQj3AYtA=="
|
||||
},
|
||||
"NetCord.Services": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.0-alpha.489",
|
||||
"contentHash": "SwG/7Khba1uRENDvG22RV/POByIwh/ZrenMrSzwoEcEYPMI5TabmEEB3ySH15XGdLcFZJEj106AlriN0kZhfFg==",
|
||||
"dependencies": {
|
||||
"NetCord": "1.0.0-alpha.489"
|
||||
}
|
||||
},
|
||||
"Npgsql.DependencyInjection": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.1",
|
||||
"contentHash": "YHFa4vD27sNIfv6s5q8Zi1fLvKfmK1xcpMv0PUvXOxDFbRmuMRSHwpZTbPvsAlj97q1/o7DfyynLqfqrCm1VnA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
||||
"Npgsql": "10.0.1"
|
||||
}
|
||||
},
|
||||
"Npgsql.OpenTelemetry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.1",
|
||||
"contentHash": "G9fEIBaHggZXWfDSDnKLc0XwKcbuU6i2eXp7zDqpgYxbhCmIN9fRgaSOGyyMNHSo/yY1IB4G4CjW5VO/SKRR0g==",
|
||||
"dependencies": {
|
||||
"Npgsql": "10.0.1",
|
||||
"OpenTelemetry.API": "1.14.0"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.15.3",
|
||||
"contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0",
|
||||
"Microsoft.Extensions.Logging.Configuration": "10.0.0",
|
||||
"OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry.Api": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.15.3",
|
||||
"contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g=="
|
||||
},
|
||||
"OpenTelemetry.Api.ProviderBuilderExtensions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.15.3",
|
||||
"contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0",
|
||||
"OpenTelemetry.Api": "1.15.3"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.15.3",
|
||||
"contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
|
||||
"dependencies": {
|
||||
"OpenTelemetry": "1.15.3"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry.Extensions.Hosting": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.15.3",
|
||||
"contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.0",
|
||||
"OpenTelemetry": "1.15.3"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry.Instrumentation.AspNetCore": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.15.2",
|
||||
"contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==",
|
||||
"dependencies": {
|
||||
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry.Instrumentation.Http": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.15.1",
|
||||
"contentHash": "vFO4Fj/dXkoVNGo/nhoGpO2zYQmZwr4jTID7oRGo+XlQ8LqksyZjUXQ4p39RfUvTID7IzzL8Qe71tW7CcAFymA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.0",
|
||||
"Microsoft.Extensions.Options": "10.0.0",
|
||||
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry.Instrumentation.Runtime": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.15.1",
|
||||
"contentHash": "cpPwlUT5HXcLGPaIgsbSy0W9eFYAPGVbTP1p8/uyQ4Osvf5BJuPpEXE7crL09SmEd44r0DGNKDtsqxaAz0HxQw==",
|
||||
"dependencies": {
|
||||
"OpenTelemetry.Api": "[1.15.3, 2.0.0)"
|
||||
}
|
||||
},
|
||||
"Polly.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g=="
|
||||
},
|
||||
"Polly.Extensions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
|
||||
"Microsoft.Extensions.Options": "8.0.0",
|
||||
"Polly.Core": "8.4.2"
|
||||
}
|
||||
},
|
||||
"Polly.RateLimiting": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.4.2",
|
||||
"contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
|
||||
"dependencies": {
|
||||
"Polly.Core": "8.4.2",
|
||||
"System.Threading.RateLimiting": "8.0.0"
|
||||
}
|
||||
},
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
|
||||
},
|
||||
"System.Threading.RateLimiting": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q=="
|
||||
},
|
||||
"gmrelay.servicedefaults": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Http.Resilience": "[10.2.0, )",
|
||||
"Microsoft.Extensions.ServiceDiscovery": "[10.2.0, )",
|
||||
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
|
||||
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
|
||||
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
|
||||
"OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
|
||||
"OpenTelemetry.Instrumentation.Runtime": "[1.15.1, )"
|
||||
}
|
||||
},
|
||||
"gmrelay.shared": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace GmRelay.Shared.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Заглушка для Discord-рендерера.
|
||||
/// Реальная реализация будет добавлена в проект GmRelay.DiscordBot (issue #26).
|
||||
/// </summary>
|
||||
public static class DiscordSessionBatchRenderer
|
||||
{
|
||||
public static object Render(SessionBatchViewModel view)
|
||||
{
|
||||
throw new NotImplementedException("Discord renderer will be implemented in issue #26.");
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v2.1.1</div>
|
||||
<div class="nav-version">v2.3.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using GmRelay.DiscordBot;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordOptionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_ShouldRejectMissingToken(string? token)
|
||||
{
|
||||
var options = new DiscordOptions { Token = token };
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
|
||||
Assert.Contains("Discord:Token is required", exception.Message);
|
||||
Assert.Contains("Discord__Token", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ShouldAcceptConfiguredToken()
|
||||
{
|
||||
var options = new DiscordOptions { Token = "configured-token" };
|
||||
|
||||
options.Validate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordProjectStructureTests
|
||||
{
|
||||
private static string GetRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Solution_ShouldIncludeDiscordWorkerProject()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var solution = File.ReadAllText(Path.Combine(repoRoot, "GM-Relay.slnx"));
|
||||
|
||||
Assert.Contains("src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj", solution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscordWorkerProject_ShouldExistWithoutTelegramDependency()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var projectPath = Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "GmRelay.DiscordBot.csproj");
|
||||
|
||||
Assert.True(File.Exists(projectPath), "Discord worker project should exist.");
|
||||
|
||||
var project = File.ReadAllText(projectPath);
|
||||
Assert.Contains("Microsoft.NET.Sdk.Worker", project);
|
||||
Assert.Contains("NetCord.Hosting", project);
|
||||
Assert.Contains("GmRelay.ServiceDefaults.csproj", project);
|
||||
Assert.Contains("GmRelay.Shared.csproj", project);
|
||||
Assert.DoesNotContain("Telegram.Bot", project);
|
||||
Assert.DoesNotContain("GmRelay.Bot.csproj", project);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramWorkerProject_ShouldNotReferenceNetCord()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var project = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Bot", "GmRelay.Bot.csproj"));
|
||||
|
||||
Assert.DoesNotContain("NetCord", project, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeWiring_ShouldIncludeDiscordServiceWithoutCouplingTelegram()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"));
|
||||
var appHostProject = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "GmRelay.AppHost.csproj"));
|
||||
var appHostProgram = File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.AppHost", "Program.cs"));
|
||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||
|
||||
Assert.Contains("gmrelay-discord-bot:2.3.0", compose);
|
||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||
Assert.Contains("dotnet build src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj --no-restore", prChecks);
|
||||
Assert.Contains("GmRelay.DiscordBot.csproj", appHostProject);
|
||||
Assert.Contains("Projects.GmRelay_DiscordBot", appHostProgram);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Version_ShouldBeSynchronizedForDiscordFeatureRelease()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
|
||||
Assert.Contains("<Version>2.3.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 2.3.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:2.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:2.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:2.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v2.3.0",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord;
|
||||
|
||||
public sealed class DiscordStartupTests
|
||||
{
|
||||
private static string GetRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir) && !File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
{
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
return dir ?? throw new InvalidOperationException("Could not find repo root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldValidateDiscordTokenBeforeRunning()
|
||||
{
|
||||
var program = ReadProgram();
|
||||
|
||||
Assert.Contains("GetRequiredSection(\"Discord\")", program);
|
||||
Assert.Contains("DiscordOptions", program);
|
||||
Assert.Contains(".Validate()", program);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldRegisterServiceDefaultsAndPostgresDataSource()
|
||||
{
|
||||
var program = ReadProgram();
|
||||
|
||||
Assert.Contains("builder.AddServiceDefaults()", program);
|
||||
Assert.Contains("ConnectionStrings:gmrelaydb is required", program);
|
||||
Assert.Contains("NpgsqlDataSource", program);
|
||||
Assert.Contains("SecretRedactor.RedactConnectionString", program);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_ShouldRegisterNetCordGatewayApplicationCommandsAndComponents()
|
||||
{
|
||||
var program = ReadProgram();
|
||||
|
||||
Assert.Contains(".AddDiscordGateway", program);
|
||||
Assert.Contains(".AddApplicationCommands", program);
|
||||
Assert.Contains(".AddComponentInteractions", program);
|
||||
Assert.Contains(".AddGatewayHandlers", program);
|
||||
Assert.Contains("AddSlashCommand", program);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LifecycleLogger_ShouldLogGatewayLifecycleEventsWithoutTokenValues()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
var loggerPath = Path.Combine(
|
||||
repoRoot,
|
||||
"src",
|
||||
"GmRelay.DiscordBot",
|
||||
"Infrastructure",
|
||||
"Logging",
|
||||
"DiscordGatewayLifecycleLogger.cs");
|
||||
|
||||
Assert.True(File.Exists(loggerPath), "Discord gateway lifecycle logger should exist.");
|
||||
|
||||
var logger = File.ReadAllText(loggerPath);
|
||||
Assert.Contains("IReadyGatewayHandler", logger);
|
||||
Assert.Contains("IDisconnectGatewayHandler", logger);
|
||||
Assert.Contains("IResumeGatewayHandler", logger);
|
||||
Assert.Contains("LogInformation", logger);
|
||||
Assert.DoesNotContain("Token", logger);
|
||||
}
|
||||
|
||||
private static string ReadProgram()
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
return File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.DiscordBot", "Program.cs"));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" />
|
||||
<ProjectReference Include="..\..\src\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
|
||||
<ProjectReference Include="..\..\src\GmRelay.Web\GmRelay.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Rendering;
|
||||
|
||||
public sealed class DiscordSessionBatchRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void Render_ShouldProduceEmbedsAndButtonsForMultipleSessions()
|
||||
{
|
||||
var firstSessionId = Guid.NewGuid();
|
||||
var secondSessionId = Guid.NewGuid();
|
||||
var cancelledSessionId = Guid.NewGuid();
|
||||
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2"),
|
||||
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""),
|
||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1")
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(secondSessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(secondSessionId, "Charlie", null, ParticipantRegistrationStatus.Waitlisted),
|
||||
new ParticipantBatchDto(cancelledSessionId, "Bob", null, ParticipantRegistrationStatus.Active)
|
||||
};
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Campaign", sessions, participants);
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Equal(3, embeds.Count);
|
||||
Assert.Equal(2, actionRows.Count); // cancelled skipped
|
||||
|
||||
// Embed titles contain game title and Moscow date
|
||||
Assert.Contains(embeds, e => e.Title!.Contains("Campaign") && e.Title.Contains("26"));
|
||||
Assert.Contains(embeds, e => e.Title!.Contains("Campaign") && e.Title.Contains("27"));
|
||||
Assert.Contains(embeds, e => e.Title!.Contains("Campaign") && e.Title.Contains("28"));
|
||||
|
||||
// Cancelled session embed description indicates cancellation
|
||||
var cancelledEmbed = embeds.First(e => e.Description!.Contains("отменена") || e.Description.Contains("Отменена"));
|
||||
Assert.NotNull(cancelledEmbed);
|
||||
|
||||
// Active session embeds contain player names
|
||||
Assert.Contains(embeds, e => e.Description!.Contains("Alice"));
|
||||
Assert.Contains(embeds, e => e.Description!.Contains("Charlie"));
|
||||
|
||||
// Buttons for active sessions
|
||||
var allButtons = actionRows.SelectMany(r => r).OfType<ButtonProperties>().ToList();
|
||||
Assert.Contains(allButtons, b => b.CustomId == $"join_session:{firstSessionId}");
|
||||
Assert.Contains(allButtons, b => b.CustomId == $"leave_session:{firstSessionId}");
|
||||
Assert.Contains(allButtons, b => b.CustomId == $"join_session:{secondSessionId}");
|
||||
Assert.Contains(allButtons, b => b.CustomId == $"leave_session:{secondSessionId}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldSkipActionRowsForCancelledSessions()
|
||||
{
|
||||
var cancelledSessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null, "") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Single(embeds);
|
||||
Assert.Empty(actionRows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldShowWaitlistButtonWhenFull()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") };
|
||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (_, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
||||
var buttons = actionRows.SelectMany(r => r).OfType<ButtonProperties>().ToList();
|
||||
var joinButton = buttons.First(b => b.CustomId == $"join_session:{sessionId}");
|
||||
|
||||
Assert.Contains("ожидания", joinButton.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldUseRedColorForCancelledSessions()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null, "") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Equal(0xED4245, embeds[0].Color.RawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldUseGreenColorForOpenSessions()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/game") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Equal(0x57F287, embeds[0].Color.RawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldUseYellowColorForFullSessions()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") };
|
||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Equal(0xFEE75C, embeds[0].Color.RawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldHandleRescheduleStatus()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, "Rescheduled", 4, "") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Single(embeds);
|
||||
Assert.Single(actionRows); // not cancelled → actions present
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,36 @@
|
||||
"Newtonsoft.Json": "13.0.3"
|
||||
}
|
||||
},
|
||||
"NetCord": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.0-alpha.489",
|
||||
"contentHash": "/rM73l1pwwJCWHi7YrIiSVc+GVL0lV+k+amqNJUMINjLO+c5bKWj9PoNNoMhiPZoaORO4k6Uxp8EQfoQj3AYtA=="
|
||||
},
|
||||
"NetCord.Hosting": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.0-alpha.489",
|
||||
"contentHash": "yQcvgY3uu98ndoLXpiFhJ5kungoWVLd7xnO18GmukRPVsRzyOKgxe/Ycp8DLYTtiQG9Wyg1pV4Iv6rvo+zck4w==",
|
||||
"dependencies": {
|
||||
"NetCord": "1.0.0-alpha.489"
|
||||
}
|
||||
},
|
||||
"NetCord.Hosting.Services": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.0-alpha.489",
|
||||
"contentHash": "Md46+zLB9UWYLM7PVlATytkjAC9602wBNKO7m5eaBiDdEvZOPsUrR6NJJr2YtJoKjttbvhte5ayDXj8WGGsevQ==",
|
||||
"dependencies": {
|
||||
"NetCord.Hosting": "1.0.0-alpha.489",
|
||||
"NetCord.Services": "1.0.0-alpha.489"
|
||||
}
|
||||
},
|
||||
"NetCord.Services": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.0-alpha.489",
|
||||
"contentHash": "SwG/7Khba1uRENDvG22RV/POByIwh/ZrenMrSzwoEcEYPMI5TabmEEB3ySH15XGdLcFZJEj106AlriN0kZhfFg==",
|
||||
"dependencies": {
|
||||
"NetCord": "1.0.0-alpha.489"
|
||||
}
|
||||
},
|
||||
"Newtonsoft.Json": {
|
||||
"type": "Transitive",
|
||||
"resolved": "13.0.3",
|
||||
@@ -362,13 +392,24 @@
|
||||
"Aspire.Npgsql": "[13.2.2, )",
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Dapper.AOT": "[1.0.48, )",
|
||||
"GmRelay.ServiceDefaults": "[1.15.1, )",
|
||||
"GmRelay.Shared": "[1.15.1, )",
|
||||
"GmRelay.ServiceDefaults": "[2.1.1, )",
|
||||
"GmRelay.Shared": "[2.1.1, )",
|
||||
"Npgsql": "[10.0.2, )",
|
||||
"Telegram.Bot": "[22.9.5.3, )",
|
||||
"dbup-postgresql": "[7.0.1, )"
|
||||
}
|
||||
},
|
||||
"gmrelay.discordbot": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Aspire.Npgsql": "[13.2.2, )",
|
||||
"GmRelay.ServiceDefaults": "[2.1.1, )",
|
||||
"GmRelay.Shared": "[2.1.1, )",
|
||||
"NetCord.Hosting": "[1.0.0-alpha.489, )",
|
||||
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
|
||||
"Npgsql": "[10.0.2, )"
|
||||
}
|
||||
},
|
||||
"gmrelay.servicedefaults": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -389,8 +430,8 @@
|
||||
"dependencies": {
|
||||
"Aspire.Npgsql": "[13.2.2, )",
|
||||
"Dapper": "[2.1.72, )",
|
||||
"GmRelay.ServiceDefaults": "[1.15.1, )",
|
||||
"GmRelay.Shared": "[1.15.1, )",
|
||||
"GmRelay.ServiceDefaults": "[2.1.1, )",
|
||||
"GmRelay.Shared": "[2.1.1, )",
|
||||
"Npgsql": "[10.0.2, )",
|
||||
"Telegram.Bot": "[22.9.6.1, )"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user