Compare commits
256 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| accb3b2405 | |||
| a63e3bef1e | |||
| 9d9aca53df | |||
| 5b6971fda5 | |||
| b496a401fc | |||
| 76c6818952 | |||
| 633a020212 | |||
| ab38238fe8 | |||
| 4145cacc52 | |||
| 6d59737d07 | |||
| 71ffcce06b | |||
| 72f43dbef2 | |||
| a5f4a68c6a | |||
| b2497ed877 | |||
| 9b42ea034a | |||
| f94bea3e74 | |||
| cde1e4311f | |||
| 847a40815f | |||
| 6fd03ef836 | |||
| c2ccc35e50 | |||
| 3418d1a46c | |||
| fac5d75c7e | |||
| 7a2965b43f | |||
| a0df94fc91 | |||
| 79694f7de8 | |||
| 542f15f2d6 | |||
| 64216f5a26 | |||
| 383e2c1d8d | |||
| bfa979a224 | |||
| c69ebf6c03 | |||
| 040b0a3cdb | |||
| a5aed14dd2 | |||
| 9fc434b42b | |||
| c2cc7fd9a8 | |||
| 3447acd8c4 | |||
| 56aeca5288 | |||
| 6ed0a120a0 | |||
| 682dd3fdec | |||
| c955e1572f | |||
| a9aa84af0f | |||
| dcbd9bab41 | |||
| 92d5d9c2d3 | |||
| 47d106e288 | |||
| a5624897e9 | |||
| 11e75d036a | |||
| 2942da0c35 | |||
| 549c0c96ae | |||
| dd9337dd20 | |||
| 3cc3b373e5 | |||
| f6d5281af8 | |||
| fa63886195 | |||
| 9bd5fe75c9 | |||
| d931da37ec | |||
| 9375fa45b2 | |||
| 0b45aee96d | |||
| 80e346d6b5 | |||
| eff0128d29 | |||
| 8214e052af | |||
| 2a233b2b1e | |||
| 5e3028e470 | |||
| 63193310f2 | |||
| af37f3a8ec | |||
| 66228cf106 | |||
| 9c59240f48 | |||
| baa25f2e1e | |||
| 7a2ed808c4 | |||
| dd0828a63d | |||
| 72a392e652 | |||
| e1fac04775 | |||
| 7e02e86cd6 | |||
| eb9a159dbb | |||
| 66dc53f12f | |||
| 50f5307aac | |||
| 5fa7e26f72 | |||
| 976e204102 | |||
| 9d4256353d | |||
| 543fc42a6d | |||
| bfed400b4d | |||
| d0ddf3fb58 | |||
| 654db04d44 | |||
| 3a94becf05 | |||
| 31d8f59f1e | |||
| 31e08ba073 | |||
| 7c8e14c44f | |||
| b57332bd5c | |||
| 73714c9525 | |||
| 8319edda38 | |||
| 5e1f0a00ad | |||
| 987013974c | |||
| 7249ca079d | |||
| 7fac5926fc | |||
| 9f7b772680 | |||
| 1853a7a9c7 | |||
| befb2da6a0 | |||
| d29c6c0725 | |||
| 47b22c7401 | |||
| b4a39c027f | |||
| dd9eab2e4a | |||
| 492d47a863 | |||
| fe8d5fe026 | |||
| a2fa9aaa6c | |||
| 5b65ac4a2f | |||
| feb3e08b63 | |||
| f1d8f56fec | |||
| 08ffc6694e | |||
| 3199c48fcd | |||
| 2a707e4825 | |||
| 5dbec1a0a4 | |||
| 7426000937 | |||
| 0c62631ab6 | |||
| db9a931ed6 | |||
| 35548a03cb | |||
| dda393c372 | |||
| 1e9bf4ab25 | |||
| 690aa0272f | |||
| d871f2c142 | |||
| 9712fe125b | |||
| fdfc73ae9c | |||
| e93e777fb3 | |||
| a13edf20af | |||
| fcd7de035f | |||
| fb0c29eefe | |||
| 9ff5cc4a67 | |||
| 3251846001 | |||
| 39132be4e8 | |||
| 90da33154c | |||
| d55003a2a9 | |||
| daa59335cc | |||
| 474e7f62f7 | |||
| 8666b8984e | |||
| d373ff49ba | |||
| 95aad3a2f6 | |||
| 76456cc28a | |||
| ac8f03ecc9 | |||
| 21760ae6f7 | |||
| 5dddf99288 | |||
| 1c75994722 | |||
| c0147fd310 | |||
| 745a65818d | |||
| 05ca8061e9 | |||
| ab59d234f3 | |||
| e791fc2f4a | |||
| cb515b0e05 | |||
| cea6ec801a | |||
| 8e57f8b07a | |||
| e837e191c2 | |||
| df01aa9f3e | |||
| 18e702cd04 | |||
| 5931099c14 | |||
| 8bcd16fbc9 | |||
| 7cecb722d8 | |||
| 11b145a967 | |||
| 105b3c59d7 | |||
| 3bea327043 | |||
| c6aea78ff3 | |||
| 01c49f2df0 | |||
| 9deccd3a9d | |||
| 81d4ec2c97 | |||
| c0a5482e1a | |||
| 5a18cacb2e | |||
| 121272fdfe | |||
| ccf11457ca | |||
| e492d4fc2d | |||
| 11f6b1bcc9 | |||
| 06d40fdbc8 | |||
| 043ed9ce45 | |||
| 320aba2877 | |||
| e3fdac15b5 | |||
| 105a051c2f | |||
| de9f56c97d | |||
| 007806a5d8 | |||
| c9627e51a2 | |||
| 2a3285996e | |||
| 025c7c2f9a | |||
| e6e6d17b72 | |||
| 563e118f23 | |||
| e2303490e9 | |||
| 9c1c6c2483 | |||
| c0c8f852d2 | |||
| ac6e2455a1 | |||
| 9374ff16ed | |||
| 17b92b25f4 | |||
| d2edbf16cc | |||
| b16627c2b6 | |||
| 4f7afb3bc9 | |||
| 5baf63e9ad | |||
| a0d9d1bc44 | |||
| f46f2bb5d3 | |||
| 46527fe761 | |||
| d0a25895ab | |||
| 05faa9e32d | |||
| 0dbd4064ac | |||
| 0f03da0a60 | |||
| 6d90ba8274 | |||
| 35894bf89e | |||
| 6394b1fe8c | |||
| d170c83b9e | |||
| 4a2d1d2d38 | |||
| 706f20e403 | |||
| 4d3362d93f | |||
| b03929174a | |||
| 7e2747ec73 | |||
| ae6be912e3 | |||
| 116bed16a8 | |||
| 063de7ee3e | |||
| 5c4ec562d0 | |||
| dbd481566c | |||
| 3f4571d3a7 | |||
| 8c1e7991cd | |||
| c1fdba510b | |||
| 435399dcf2 | |||
| ddaa0f4279 | |||
| b205967f1a | |||
| 7457315d6f | |||
| 59f9904d66 | |||
| 3b91a009ea | |||
| a6ae5aac31 | |||
| dc26b4d7e4 | |||
| bc6136d91e | |||
| 2e95841ca8 | |||
| a7c8127f90 | |||
| cad4e5c30e | |||
| 77647e4bb8 | |||
| 17c631aef2 | |||
| 89b5196676 | |||
| ab1d2f1683 | |||
| 1bcd88db32 | |||
| 63e613c061 | |||
| dbf59c544a | |||
| 14b9bf15f2 | |||
| 5dee2d87f5 | |||
| b71488097e | |||
| 6e92419cff | |||
| fdb3445bec | |||
| c1f5d96e25 | |||
| c874f7b797 | |||
| aefed5abd4 | |||
| 25c22b2ff5 | |||
| cb40c2438d | |||
| 2a76ec0fb8 | |||
| 57c8714889 | |||
| 8220f2060f | |||
| 41f2ea6e90 | |||
| 5082dd4fcf | |||
| cfbda4ca05 | |||
| 0218890a7a | |||
| a1ec688ec8 | |||
| 2529df4157 | |||
| a8f2b10956 | |||
| 3228e77c7f | |||
| 621ef553e7 | |||
| 5f3516e703 | |||
| 2eb7d86e48 | |||
| 3e291b0ed5 | |||
| a5ba4111cf | |||
| f45985041b |
@@ -6,8 +6,30 @@ TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
|
||||
# Найти его можно в информации о боте у @BotFather.
|
||||
TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
|
||||
|
||||
# HTTPS URL Mini App dashboard, например: https://your-domain.example/miniapp
|
||||
# Используется ботом для кнопки меню Telegram и кнопки /start.
|
||||
TELEGRAM_MINI_APP_URL=
|
||||
|
||||
# Токен Discord application bot
|
||||
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
|
||||
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
|
||||
|
||||
# Discord OAuth (для Web Dashboard)
|
||||
# Client ID и Secret из OAuth2 раздела Discord Developer Portal
|
||||
# Redirect URI должен указывать на /auth/discord/callback вашего домена
|
||||
DISCORD_CLIENT_ID=YOUR_DISCORD_CLIENT_ID_HERE
|
||||
DISCORD_CLIENT_SECRET=YOUR_DISCORD_CLIENT_SECRET_HERE
|
||||
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
|
||||
|
||||
# Пароль для базы данных PostgreSQL
|
||||
POSTGRES_PASSWORD=StrongPasswordForDatabase
|
||||
|
||||
# Локальный порт веб-интерфейса GM-Relay
|
||||
GMRELAY_WEB_PORT=8080
|
||||
|
||||
# === Backup ===
|
||||
# Сколько дней хранить дампы PostgreSQL (default: 7)
|
||||
BACKUP_RETENTION_DAYS=7
|
||||
|
||||
# Имя Docker volume для резервных копий БД
|
||||
BACKUP_VOLUME_NAME=game_pgbackups
|
||||
|
||||
+112
-26
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 1.2.0
|
||||
VERSION: 3.4.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
@@ -23,33 +23,84 @@ jobs:
|
||||
username: toutsu
|
||||
password: ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
- name: Build and push Bot
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: src/GmRelay.Bot/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
git.codeanddice.ru/toutsu/gmrelay-bot:latest
|
||||
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
|
||||
- name: Build Bot image
|
||||
run: |
|
||||
docker build \
|
||||
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
|
||||
-f src/GmRelay.Bot/Dockerfile \
|
||||
-t git.codeanddice.ru/toutsu/gmrelay-bot:latest \
|
||||
-t git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }} \
|
||||
.
|
||||
|
||||
- name: Build and push Web
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: src/GmRelay.Web/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
git.codeanddice.ru/toutsu/gmrelay-web:latest
|
||||
- name: Push Bot image
|
||||
run: |
|
||||
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 \
|
||||
--label "org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}" \
|
||||
-f src/GmRelay.Web/Dockerfile \
|
||||
-t git.codeanddice.ru/toutsu/gmrelay-web:latest \
|
||||
-t git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }} \
|
||||
.
|
||||
|
||||
- name: Push Web image
|
||||
run: |
|
||||
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
|
||||
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||
|
||||
# ЧАСТЬ 1.5: Сканируем собранные образы на уязвимости
|
||||
scan-images:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
|
||||
- name: Scan Bot image
|
||||
run: |
|
||||
trivy image \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--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 \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://git.codeanddice.ru/${{ gitea.repository }}
|
||||
|
||||
# ЧАСТЬ 2: Запускаем эти образы на самом сервере
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
needs: scan-images
|
||||
runs-on: ubuntu-latest # Тот же локальный раннер
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -59,15 +110,50 @@ 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
|
||||
echo "DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }}" >> .env
|
||||
echo "DISCORD_CLIENT_SECRET=${{ secrets.DISCORD_CLIENT_SECRET }}" >> .env
|
||||
echo "DISCORD_REDIRECT_URI=${{ secrets.DISCORD_REDIRECT_URI }}" >> .env
|
||||
|
||||
- name: Deploy Containers
|
||||
run: |
|
||||
# Авторизуемся локальным докером в нашей Gitea
|
||||
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
|
||||
|
||||
# Ждём, пока сервисы перейдут в healthy или упадут
|
||||
SERVICES="bot discord web"
|
||||
MAX_WAIT=40
|
||||
INTERVAL=5
|
||||
ELAPSED=0
|
||||
|
||||
while [ $ELAPSED -lt $MAX_WAIT ]; do
|
||||
NOT_HEALTHY=0
|
||||
for svc in $SERVICES; do
|
||||
HEALTH=$(docker compose ps $svc --format="{{.Health}}" 2>/dev/null | head -n1)
|
||||
if [ "$HEALTH" != "healthy" ]; then
|
||||
STATE=$(docker compose ps $svc --format="{{.State}}" 2>/dev/null | head -n1)
|
||||
echo "❌ $svc not healthy yet (state: ${STATE:-unknown})"
|
||||
NOT_HEALTHY=$((NOT_HEALTHY + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $NOT_HEALTHY -eq 0 ]; then
|
||||
echo "✅ All services are healthy!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep $INTERVAL
|
||||
ELAPSED=$((ELAPSED + INTERVAL))
|
||||
done
|
||||
|
||||
echo "⏰ Timed out waiting for services to become healthy"
|
||||
docker compose ps
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Verify Trivy dependency scan inputs
|
||||
run: |
|
||||
lock_count="$(find . -name packages.lock.json -not -path "*/bin/*" -not -path "*/obj/*" | tee trivy-targets.txt | wc -l)"
|
||||
echo "Trivy NuGet lock files: ${lock_count}"
|
||||
if [ "${lock_count}" -eq 0 ]; then
|
||||
echo "::error::No packages.lock.json files found. Trivy would scan 0 NuGet dependency files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Linting ──
|
||||
|
||||
- name: Lint C# code style
|
||||
run: dotnet format --verify-no-changes --verbosity diagnostic
|
||||
|
||||
# ── Security ──
|
||||
|
||||
- name: Check NuGet packages for vulnerabilities
|
||||
run: |
|
||||
dotnet list package --vulnerable --include-transitive 2>&1 | tee nuget-audit.txt
|
||||
if grep -qi "has the following vulnerable packages" nuget-audit.txt; then
|
||||
echo "::error::Vulnerable NuGet packages found!"
|
||||
exit 1
|
||||
fi
|
||||
echo "No vulnerable packages detected."
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
trivy --version
|
||||
|
||||
- name: Trivy filesystem security scan
|
||||
run: |
|
||||
set +e
|
||||
trivy fs --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
|
||||
trivy_exit="${PIPESTATUS[0]}"
|
||||
if ! grep -Eq "Number of language-specific files[[:space:]]+num=[1-9][0-9]*" trivy-scan.log; then
|
||||
echo "::error::Trivy did not detect any language-specific dependency files."
|
||||
exit 1
|
||||
fi
|
||||
exit "${trivy_exit}"
|
||||
|
||||
# ── Build (includes SAST via SecurityCodeScan Roslyn analyzer) ──
|
||||
|
||||
- name: Build Shared
|
||||
run: dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
|
||||
|
||||
- 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
|
||||
|
||||
# ── Tests ──
|
||||
|
||||
- name: Run tests
|
||||
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,144 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
This is a .NET 10 solution using the modern XML-based `.slnx` format. The global SDK version is `10.0.100` with `rollForward: latestFeature`.
|
||||
|
||||
**Build the solution:**
|
||||
```bash
|
||||
dotnet build
|
||||
```
|
||||
|
||||
**Build individual projects (the CI does this to include SAST via SecurityCodeScan):**
|
||||
```bash
|
||||
dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
|
||||
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
|
||||
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
|
||||
```
|
||||
|
||||
**Run all tests:**
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
|
||||
```
|
||||
|
||||
**Run a single test class or method:**
|
||||
```bash
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~YourTestClassName"
|
||||
```
|
||||
|
||||
**Lint and format:**
|
||||
```bash
|
||||
dotnet format --verify-no-changes --verbosity diagnostic # CI enforcement
|
||||
dotnet format # Apply fixes
|
||||
```
|
||||
|
||||
**Check for vulnerable packages:**
|
||||
```bash
|
||||
dotnet list package --vulnerable --include-transitive
|
||||
```
|
||||
|
||||
**Restore with lock file verification:**
|
||||
The repo enforces `RestorePackagesWithLockFile=true`. After adding or updating packages, commit the updated `packages.lock.json` files or the Trivy scan in CI will fail.
|
||||
|
||||
**Run locally with Aspire (dev orchestration):**
|
||||
```bash
|
||||
dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj
|
||||
```
|
||||
This automatically starts PostgreSQL in a container, the Bot, and the Web dashboard.
|
||||
|
||||
**Run locally with Docker Compose (production-like):**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, POSTGRES_PASSWORD
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
### Project Roles and Runtime Model
|
||||
|
||||
| Project | Runtime | Key Trait |
|
||||
|---|---|---|
|
||||
| `GmRelay.Bot` | `Microsoft.NET.Sdk.Worker` | **Native AOT** binary. Telegram long polling bot + stateless scheduler. |
|
||||
| `GmRelay.Web` | `Microsoft.NET.Sdk.Web` | Blazor Server dashboard. Cookie auth via Telegram Login Widget / Mini App `initData`. |
|
||||
| `GmRelay.Shared` | Plain library | Domain models and platform-neutral view builders. **Must not depend on `Telegram.Bot`**. |
|
||||
| `GmRelay.ServiceDefaults` | Aspire shared project | OpenTelemetry, health checks, HTTP resilience. Referenced by both Bot and Web. |
|
||||
| `GmRelay.AppHost` | Aspire orchestrator | Dev-only. Spins up PostgreSQL and wires Bot + Web with service discovery. |
|
||||
|
||||
**Important:** `README.md` references `GmRelay.Migrator` and `GmRelay.Worker`, but these projects do not exist. Migrations (`DbUp`) and background workers (`BackgroundService`) live inside `GmRelay.Bot`.
|
||||
|
||||
### Vertical Slice Architecture with Explicit DI
|
||||
|
||||
Each use case is a self-contained vertical slice: a C# record (Command/Query) + Handler class with all logic (SQL, Telegram API calls, validation). There are no abstract repository interfaces or service layers.
|
||||
|
||||
Because the Bot is compiled as Native AOT (`PublishAot=true`, `EnableTrimAnalyzer=true`), **all DI registrations are explicit** in `src/GmRelay.Bot/Program.cs`. There is no assembly scanning or reflection-based discovery. When adding a new handler, you must register it manually in Program.cs.
|
||||
|
||||
### Database Access: Npgsql + Dapper.AOT + DbUp
|
||||
|
||||
**No EF Core** — it is incompatible with Native AOT. The stack is:
|
||||
- **Npgsql** ADO.NET for connections.
|
||||
- **Dapper 2.1.72** with **Dapper.AOT 1.0.48** for compile-time source-generated mapping (AOT-safe).
|
||||
- **DbUp 7.0.1** for migrations. SQL scripts are embedded resources in `src/GmRelay.Bot/Migrations/` (V001 through V015).
|
||||
- `DbMigrator.MigrateUp()` runs on every Bot startup.
|
||||
|
||||
Both Bot and Web share the same PostgreSQL database. Web registers `NpgsqlDataSource` via `builder.AddNpgsqlDataSource("gmrelaydb")` (Aspire integration), while Bot registers it manually to avoid reflection-based Aspire configuration at AOT time.
|
||||
|
||||
### Platform-Neutral Rendering (ADR-002)
|
||||
|
||||
Rendering is split into two stages:
|
||||
1. **View Builder** (`GmRelay.Shared`) — platform-agnostic view model from domain DTOs.
|
||||
2. **Platform Renderer** — `TelegramSessionBatchRenderer` lives in both `GmRelay.Bot` and `GmRelay.Web` (temporary duplication until a third Telegram consumer justifies extracting `GmRelay.Shared.Telegram`).
|
||||
|
||||
This means `GmRelay.Shared` must remain free of `Telegram.Bot` types. If you need to add rendering logic that produces `InlineKeyboardMarkup`, it belongs in the Bot or Web project, not Shared.
|
||||
|
||||
### Stateless Scheduling
|
||||
|
||||
The session scheduler (`SessionSchedulerService`) is a `BackgroundService` with a `PeriodicTimer(TimeSpan.FromMinutes(1))`. On each tick it queries PostgreSQL for sessions needing action (T-24h confirmation, T-5min join link) and updates their status. There is no in-memory state — the database is the single source of truth. This design was chosen specifically because Quartz.NET is incompatible with Native AOT.
|
||||
|
||||
### Health Checks
|
||||
|
||||
- **Bot:** Custom `BotHealthCheckHostedService` listens on port 8081. The Docker health check hits `localhost:8081/health`.
|
||||
- **Web:** Standard ASP.NET Core health checks on `/health` (JSON response with status and timestamp) and `/alive` (liveness probe tag filter). Exposed via `GmRelay.ServiceDefaults`.
|
||||
|
||||
### Authentication and Security
|
||||
|
||||
- **Telegram Login Widget** and **Mini App `initData`** verification via HMAC-SHA256. Cookie auth is hardened (`HttpOnly`, `SecurePolicy.Always`, `SameSite.Strict`).
|
||||
- Web Data Protection keys are persisted to `/app/dataprotection-keys` (Docker volume `web_keys`).
|
||||
- Security headers middleware (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`) is applied globally in Web.
|
||||
- `SecurityCodeScan.VS2019` (5.6.7) is included in all projects via `Directory.Build.props` for SAST at build time.
|
||||
- Connection string passwords are redacted in logs via `SecretRedactor`.
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
`.gitea/workflows/pr-checks.yml` runs on every PR to `main`:
|
||||
1. `dotnet restore`
|
||||
2. Verify `packages.lock.json` files exist for Trivy
|
||||
3. `dotnet format --verify-no-changes`
|
||||
4. `dotnet list package --vulnerable`
|
||||
5. Trivy filesystem scan (`vuln,misconfig,secret`, HIGH/CRITICAL)
|
||||
6. Build Shared → Bot → Web
|
||||
7. Run tests
|
||||
|
||||
`.gitea/workflows/deploy.yml` runs on push to `main`:
|
||||
1. Build and push `gmrelay-bot` and `gmrelay-web` images to `git.codeanddice.ru/toutsu/...`
|
||||
2. Trivy image scan on both images (HIGH/CRITICAL, exit-code 1)
|
||||
3. Create `.env` from secrets and run `docker compose up -d`
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
Key environment variables (see `.env.example`):
|
||||
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_USERNAME`, `TELEGRAM_MINI_APP_URL`
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `GMRELAY_WEB_PORT` (default 8080)
|
||||
- `ConnectionStrings__gmrelaydb` — used by both Bot and Web
|
||||
|
||||
The Bot reads config as `Telegram:BotToken` (colon) which maps from `Telegram__BotToken` (double underscore) via environment variables.
|
||||
|
||||
### Docker Images
|
||||
|
||||
- **Bot:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble` with `clang` and `zlib1g-dev` for AOT compilation. Final stage uses `runtime-deps:10.0-noble`. Exposes 8081.
|
||||
- **Web:** Multi-stage Dockerfile. Build stage uses `sdk:10.0-noble`. Final stage uses `aspnet:10.0-noble` with `libgssapi-krb5-2` and `wget`. Exposes 8080.
|
||||
|
||||
Both images are built for multi-arch (`linux/amd64`, `linux/arm64`) to support Raspberry Pi 5 (ARM64) deployment.
|
||||
@@ -1,10 +1,15 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.2.0</Version>
|
||||
<Version>3.4.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Toutsu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Discord /newsession и /listsessions — Issue #28
|
||||
|
||||
## Что реализовано
|
||||
- Slash-команда /newsession для создания игровых сессий прямо из Discord.
|
||||
- Slash-команда /listsessions для просмотра предстоящих игр в сервере.
|
||||
- DiscordPermissionChecker — проверка прав (owner / admin / manager).
|
||||
- DiscordPlatformMessenger — реализация IPlatformMessenger для Discord (NetCord REST).
|
||||
- Полная интеграция в DI (Program.cs).
|
||||
|
||||
## Архитектура
|
||||
- Vertical slice: каждая команда — отдельный файл (Command + Handler).
|
||||
- Platform-agnostic SQL: используются колонки platform, external_group_id, external_user_id.
|
||||
- Рендеринг переиспользует существующий DiscordSessionBatchRenderer.
|
||||
|
||||
## TDD
|
||||
- 212 тестов, все зелёные.
|
||||
- Source-level тесты проверяют паттерны: Dapper, Npgsql, транзакции, CancellationToken, платформенную нейтральность.
|
||||
|
||||
## Версия
|
||||
- Minor bump: 2.3.0 → 2.4.0
|
||||
- Синхронизировано: Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor.
|
||||
|
||||
Closes #28
|
||||
@@ -1,145 +1,201 @@
|
||||
# 🎲 GM-Relay: TTRPG Session Scheduling Bot & Web Dashboard
|
||||
|
||||
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
|
||||
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота, Discord worker и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
|
||||
|
||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||
|
||||
**Текущая версия:** `v3.4.0`.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Ключевые возможности
|
||||
## ✨ Key Features
|
||||
|
||||
### 🤖 Telegram Бот
|
||||
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
|
||||
- **✋ Интерактивная запись**: Игроки записываются на конкретные даты нажатием одной кнопки.
|
||||
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию и автоматически ведёт очередь ожидания.
|
||||
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
|
||||
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
|
||||
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
|
||||
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
|
||||
### 🤖 Telegram Bot
|
||||
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
|
||||
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
|
||||
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
||||
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
||||
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
|
||||
- **📁 Поддержка Форумов (Telegram Topics)**: Если `/newsession` запущен в теме форума Telegram, расписание и групповые уведомления остаются в этой теме; при запуске из корня форума бот создает отдельную тему и сообщает о необходимости прав admin/Manage Topics, если их не хватает.
|
||||
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
|
||||
- **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном.
|
||||
- **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
|
||||
- **🕐 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
|
||||
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
||||
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в подключенных Telegram- и Discord-каналах.
|
||||
|
||||
### Discord Bot
|
||||
- **Slash-команды `/newsession` и `/listsessions`**: GM создаёт сессии и публикует актуальное расписание прямо в Discord-канале.
|
||||
- **Кнопки Join/Leave с ephemeral-ответами**: игроки нажимают Join/Leave в Discord-сообщении; бот отвечает ephemeral-сообщением и обновляет schedule message.
|
||||
- **RSVP (подтверждения) за 24ч до сессии**: scheduler публикует запрос подтверждения в Discord-канале, игроки отвечают кнопками, а GM получает итоги RSVP.
|
||||
- **DM-напоминания за 1ч и ссылки перед игрой**: one-hour reminders и join-link notifications отправляются в Discord DM при включённых личных уведомлениях; сбои DM логируются без публичного fallback.
|
||||
- **Reschedule voting (голосование за перенос)**: deadline-сервис обновляет Discord vote message и schedule message через `IPlatformMessenger`.
|
||||
- **Лимиты и waitlist**: при заполненном составе игрок попадает в waitlist, а при выходе участника первый ожидающий автоматически продвигается в основной состав.
|
||||
|
||||
### 🌐 Web Dashboard (Blazor Server)
|
||||
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
|
||||
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
|
||||
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
||||
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
|
||||
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
|
||||
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
|
||||
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
||||
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
|
||||
- **📦 Bulk-операции для Batch Sessions**:
|
||||
- обновить общий `title`/`link` у всей пачки;
|
||||
- перенести пачку на фиксированный шаг в днях;
|
||||
- клонировать batch на следующую неделю или месяц.
|
||||
- **⬆️ Управление очередью**: Заполненность, лист ожидания и ручное повышение игрока из очереди.
|
||||
- **📜 История изменений сессий**: Страница `/session/{id}/history` показывает аудит-лог всех значимых изменений (время, ссылка, название, участники, статус) с указанием акторов и дат.
|
||||
- **📊 Статистика посещаемости**: Страница `/group/{id}/stats` показывает долю присутствия, количество пропусков и среднюю явку по каждому игроку группы.
|
||||
- **🔄 Автосинхронизация**: Изменения в вебе мгновенно перерисовывают platform message расписания через `IPlatformMessenger`.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Технологический стек
|
||||
|
||||
- **Язык**: C# 14 (.NET 10)
|
||||
- **Архитектура**: Vertical Slice Architecture, общая библиотека (`GmRelay.Shared`) для доменной логики.
|
||||
- **Бот**: Telegram.Bot, Native AOT.
|
||||
- **Веб-интерфейс**: Blazor Server.
|
||||
- **Оркестрация**: .NET Aspire (`GmRelay.AppHost`).
|
||||
- **База данных**: PostgreSQL
|
||||
- **ORM**: Dapper (с использованием Dapper.AOT для source generators).
|
||||
- **Миграции**: DbUp.
|
||||
- **Развертывание**: Docker Compose + Multi-arch (AMD64/ARM64).
|
||||
| Компонент | Технология |
|
||||
|---|---|
|
||||
| Язык | C# 14 (.NET 10) |
|
||||
| Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
|
||||
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker внутри `GmRelay.Bot`) |
|
||||
| Веб | Blazor Server |
|
||||
| Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
|
||||
| БД | PostgreSQL |
|
||||
| ORM | Dapper + **Dapper.AOT** (source generators) |
|
||||
| Миграции | DbUp |
|
||||
| Развёртывание | Docker Compose, Multi-arch (**AMD64/ARM64**) |
|
||||
|
||||
> [!NOTE]
|
||||
> При использовании Dapper в режиме Native AOT все SQL-запросы используют строго типизированные DTO; динамические типы (`dynamic`) не поддерживаются.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Быстрый старт (Docker Compose)
|
||||
|
||||
Проект использует Docker Compose для одновременного запуска базы данных, бота и веб-интерфейса.
|
||||
|
||||
### 1. Подготовка
|
||||
Убедитесь, что у вас установлены **Docker** и **Docker Compose**.
|
||||
|
||||
### 2. Настройка окружения
|
||||
Скопируйте файл-шаблон и заполните его значениями:
|
||||
**Требования:** Docker и Docker Compose.
|
||||
|
||||
### 1. Настройка окружения
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Отредактируйте `.env`:
|
||||
|
||||
**Ключевые переменные `.env`:**
|
||||
```env
|
||||
# Токен вашего бота от @BotFather (используется и для бота, и как секретный ключ для веб-авторизации)
|
||||
# Токен от @BotFather (используется ботом и как секретный ключ веб-авторизации)
|
||||
TELEGRAM_BOT_TOKEN=ваш_токен_здесь
|
||||
|
||||
# Имя вашего бота в Telegram (без @), например: GmRelayBot.
|
||||
# Найти его можно в информации о боте у @BotFather.
|
||||
# Используется для работы виджета авторизации (Telegram Login Widget).
|
||||
# Токен Discord application bot
|
||||
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
|
||||
|
||||
# Discord OAuth (для Web Dashboard)
|
||||
DISCORD_CLIENT_ID=ваш_discord_client_id_здесь
|
||||
DISCORD_CLIENT_SECRET=ваш_discord_client_secret_здесь
|
||||
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
|
||||
|
||||
# Имя бота без @ (для Telegram Login Widget)
|
||||
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
|
||||
|
||||
# Пароль для базы данных PostgreSQL
|
||||
POSTGRES_PASSWORD=ваш_надежный_пароль
|
||||
# HTTPS URL Mini App, например https://your-domain.example/miniapp
|
||||
TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp
|
||||
|
||||
# Локальный порт веб-интерфейса GM-Relay
|
||||
POSTGRES_PASSWORD=ваш_надежный_пароль
|
||||
GMRELAY_WEB_PORT=8080
|
||||
```
|
||||
|
||||
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
|
||||
**Настройка в @BotFather:**
|
||||
- Команда `/setdomain` для работы виджета авторизации на вашем домене.
|
||||
- Для Mini App настройте домен Web Dashboard и menu button на URL из `TELEGRAM_MINI_APP_URL`.
|
||||
- Начиная с **v1.9.3** дополнительных действий для фикса входа не требуется: fallback выполняется внутри активного Telegram WebView по тому же HTTPS-адресу `/miniapp`.
|
||||
|
||||
### 3. Запуск
|
||||
Выполните команду:
|
||||
### 2. Запуск
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Инфраструктура автоматически:
|
||||
- Создаст локальную Docker-сеть и volume PostgreSQL, если их ещё нет.
|
||||
- Поднимет PostgreSQL, доступный для контейнеров как `db:5432`.
|
||||
- Запустит бота (применив миграции БД).
|
||||
- Запустит веб-интерфейс на `http://localhost:8080` или другом порту из `GMRELAY_WEB_PORT`.
|
||||
|
||||
---
|
||||
**Автоматически выполняется:**
|
||||
- создание Docker-сети и volume PostgreSQL;
|
||||
- подъём PostgreSQL (`db:5432`);
|
||||
- запуск бота с плавной миграцией (DbUp);
|
||||
- запуск Discord Gateway worker на NetCord (healthcheck на `:8082`);
|
||||
- запуск веб-приложения с подключением к БД и Telegram API.
|
||||
|
||||
## ⚙️ Настройка бота в Telegram
|
||||
### 3. Первоначальная настройка
|
||||
1. Напишите боту `/start`.
|
||||
2. Создайте группу через `/newgroup`.
|
||||
3. Откройте Mini App или Web Dashboard для расширенного управления.
|
||||
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
|
||||
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
|
||||
|
||||
Чтобы бот работал корректно:
|
||||
1. **Добавьте бота в группу** (или Супергруппу/Форум).
|
||||
2. **Назначьте бота Администратором**.
|
||||
3. **Необходимые права**:
|
||||
* `Выбор тем` (Managed Topics) — **обязательно** для Форумов.
|
||||
* `Отправка сообщений`.
|
||||
* `Закрепление сообщений` — рекомендуется.
|
||||
## 💾 Backup и восстановление
|
||||
|
||||
> [!TIP]
|
||||
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе.
|
||||
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
|
||||
|
||||
---
|
||||
### Как это работает
|
||||
- **Каждый день в 03:00** выполняется `pg_dump` базы `gmrelay_db`.
|
||||
- Дампы сжимаются (`gzip`) и сохраняются в volume `pgbackups` (`/backups`).
|
||||
- Формат имени: `gmrelay_db_YYYYMMDD_HHMMSS.sql.gz`.
|
||||
- Ротация: по умолчанию хранятся последние **7 дней** (настраивается через `BACKUP_RETENTION_DAYS`).
|
||||
|
||||
## 📝 Инструкция для Мастера
|
||||
|
||||
### Создание расписания игр
|
||||
Используйте команду `/newsession` с описанием в следующем формате:
|
||||
|
||||
```text
|
||||
/newsession
|
||||
Название: Легенды Берега Мечей (D&D 5e)
|
||||
Время: 15.05.2024 19:30
|
||||
Время: 22.05.2024 19:00
|
||||
Мест: 4
|
||||
Ссылка: https://discord.gg/invite-link
|
||||
### Проверка бэкапов
|
||||
```bash
|
||||
docker compose exec db-backup ls -la /backups
|
||||
```
|
||||
|
||||
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
|
||||
### Ручное создание дампа
|
||||
```bash
|
||||
docker compose exec db-backup sh -c "pg_dump -h db -U gmrelay -d gmrelay_db | gzip > /backups/gmrelay_db_manual.sql.gz"
|
||||
```
|
||||
|
||||
### Другие команды
|
||||
- `/listsessions` — Показать список всех актуальных игр в этой группе.
|
||||
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
|
||||
- `/deletesession` — Удалить сессию.
|
||||
- `/exportcalendar` — Получить `.ics` файл с играми.
|
||||
- `/help` — Справка по формату.
|
||||
### Восстановление из бэкапа
|
||||
```bash
|
||||
# Использовать последний автоматический бэкап
|
||||
./scripts/restore.sh
|
||||
|
||||
# Или указать конкретный файл
|
||||
./scripts/restore.sh backups/gmrelay_db_20260512_030000.sql.gz
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Восстановление **перезаписывает текущую базу данных**. Убедитесь, что вы понимаете последствия, прежде чем запускать `restore.sh`.
|
||||
|
||||
### Переменные окружения (опциональные)
|
||||
```env
|
||||
BACKUP_RETENTION_DAYS=7
|
||||
BACKUP_VOLUME_NAME=game_pgbackups
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗 Разработка и запуск локально (.NET Aspire)
|
||||
## 🗂 Структура репозитория
|
||||
|
||||
Для локальной разработки проще всего использовать .NET Aspire:
|
||||
```
|
||||
├── src/
|
||||
│ ├── GmRelay.AppHost/ # .NET Aspire orchestrator
|
||||
│ ├── GmRelay.Bot/ # Telegram- и Discord-бот (Native AOT + NetCord Gateway worker)
|
||||
│ ├── GmRelay.ServiceDefaults/ # Aspire service defaults
|
||||
│ ├── GmRelay.Shared/ # Общие доменные модели
|
||||
│ └── GmRelay.Web/ # Blazor Server dashboard
|
||||
├── tests/
|
||||
│ └── GmRelay.Bot.Tests/ # xUnit + NSubstitute
|
||||
├── compose.yaml # Docker Compose (AMD64 + ARM64)
|
||||
└── .env.example # Шаблон переменных окружения
|
||||
```
|
||||
|
||||
1. Установите [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) и workload Aspire.
|
||||
2. Откройте решение `GM-Relay.slnx`.
|
||||
3. Установите переменные окружения (или user secrets) для `GmRelay.AppHost`.
|
||||
4. Запустите проект `GmRelay.AppHost`. Aspire Dashboard запустится автоматически, предоставляя удобный мониторинг БД, бота и веб-интерфейса.
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> При использовании **Dapper** в режиме Native AOT, все SQL-запросы используют строго типизированные DTO. Динамические типы (`dynamic`) не поддерживаются.
|
||||
## 👨💻 Для разработчиков
|
||||
|
||||
- **Архитектура**: проект следует Vertical Slice с явным DI. Подробности — в [ADR-001](docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md) и [ADR-002](docs/adr/002-platform-neutral-batch-rendering.md).
|
||||
- **Добавление обработчика**: из-за Native AOT все DI-регистрации выполняются вручную в `src/GmRelay.Bot/Program.cs` (assembly scanning не используется).
|
||||
- **Миграции**: SQL-скрипты добавляются как embedded resources в `src/GmRelay.Bot/Migrations/` и применяются автоматически при старте бота через DbUp.
|
||||
- **Тесты**: `dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal`
|
||||
- **Сборка**: `dotnet build`
|
||||
|
||||
---
|
||||
|
||||
## 📜 Лицензия
|
||||
Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется.
|
||||
|
||||
MIT License. См. [LICENSE](./LICENSE).
|
||||
|
||||
---
|
||||
|
||||
*Построено с ❤️ для TTRPG-сообщества.*
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
## 🛠 Patch 2.4.0 — Discord /newsession и /listsessions
|
||||
|
||||
Реализованы slash-команды Discord для создания сессий и просмотра расписания без Web Dashboard.
|
||||
|
||||
## 🧩 Что вошло в релиз
|
||||
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionCommand.cs — slash-команда /newsession с параметрами (title, time, seats, link)
|
||||
- src/GmRelay.DiscordBot/Features/Sessions/DiscordNewSessionHandler.cs — handler создания batch + session в БД
|
||||
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsCommand.cs — slash-команда /listsessions
|
||||
- src/GmRelay.DiscordBot/Features/Sessions/DiscordListSessionsHandler.cs — handler запроса активных сессий с embed-рендерингом
|
||||
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPermissionChecker.cs — проверка прав через Discord permissions bitflag (Administrator = 0x8)
|
||||
- src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs — реализация IPlatformMessenger для Discord через NetCord REST
|
||||
- src/GmRelay.DiscordBot/Program.cs — регистрация DI: handlers, permission checker, messenger
|
||||
- ests/GmRelay.Bot.Tests/Discord/ — 20+ TDD-тестов на парсинг, права, структуру, DI, рендеринг
|
||||
- Синхронизированы версии: Directory.Build.props, NavMenu.razor, compose.yaml, deploy.yml → 2.4.0
|
||||
|
||||
## 🗺 Что это даёт
|
||||
- Мастера (GM) могут создавать сессии прямо из Discord, не заходя в Web.
|
||||
- Участники сервера видят расписание через /listsessions.
|
||||
- Единая PostgreSQL модель для Telegram и Discord — никакого дублирования данных.
|
||||
|
||||
## 📦 Версия и деплой
|
||||
- версия обновлена до 2.4.0
|
||||
- Docker-образы используют тег 2.4.0
|
||||
+68
-2
@@ -16,8 +16,40 @@ services:
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
db-backup:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_USER: gmrelay
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_DB: gmrelay_db
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
|
||||
volumes:
|
||||
- pgbackups:/backups
|
||||
networks:
|
||||
- gmrelay
|
||||
entrypoint: ["sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
cat > /usr/local/bin/backup.sh << 'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
TMPFILE="/tmp/backup_$$.sql"
|
||||
pg_dump -h db -U gmrelay -d gmrelay_db > "$TMPFILE"
|
||||
gzip "$TMPFILE"
|
||||
mv "$TMPFILE.gz" "/backups/gmrelay_db_$(date +%Y%m%d_%H%M%S).sql.gz"
|
||||
find /backups -name 'gmrelay_db_*.sql.gz' -type f -mtime +${BACKUP_RETENTION_DAYS} -delete
|
||||
EOF
|
||||
chmod +x /usr/local/bin/backup.sh
|
||||
echo "0 3 * * * /usr/local/bin/backup.sh" | crontab -
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.2.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.4.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -25,11 +57,34 @@ services:
|
||||
environment:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
|
||||
networks:
|
||||
- gmrelay
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.4.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
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.2.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.4.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -38,18 +93,29 @@ services:
|
||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
|
||||
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
|
||||
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
|
||||
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
|
||||
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
|
||||
ports:
|
||||
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- web_keys:/app/dataprotection-keys
|
||||
networks:
|
||||
- gmrelay
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
|
||||
web_keys:
|
||||
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
|
||||
pgbackups:
|
||||
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
|
||||
|
||||
networks:
|
||||
gmrelay:
|
||||
|
||||
@@ -56,8 +56,18 @@ Aspire обеспечивает:
|
||||
- Service discovery и передачу connection strings.
|
||||
- OpenTelemetry (traces, metrics, logs) из коробки.
|
||||
- Aspire Dashboard для мониторинга.
|
||||
- **Три сервиса:** Bot (Telegram long polling + Discord Gateway), Web, PostgreSQL.
|
||||
|
||||
### 5. Telegram.Bot 22.x + Long Polling
|
||||
### 5. Discord Gateway + NetCord
|
||||
|
||||
Discord-интеграция реализована через NetCord Gateway (не DSharpPlus) из-за:
|
||||
- Нативной совместимости с .NET 10 и минимального размера зависимостей.
|
||||
- Gateway events маршрутизируются в те же vertical slice handlers, что и Telegram updates.
|
||||
- Slash-команды регистрируются через NetCord `ApplicationCommandService`.
|
||||
|
||||
Ephemeral-ответы (кнопки Join/Leave/RSVP) используют `InteractionMessageProperties` с `Flags = MessageFlags.Ephemeral`.
|
||||
|
||||
### 6. Telegram.Bot 22.x + Long Polling
|
||||
|
||||
- Long Polling — единственный вариант для Pi за NAT.
|
||||
- Telegram.Bot поддерживает `System.Text.Json` source generators для AOT.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR 002: Platform-Neutral Batch Rendering
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted** — implemented in v1.10.0 (PR #42).
|
||||
|
||||
## Context
|
||||
|
||||
`SessionBatchRenderer` жил в `GmRelay.Shared` и напрямую зависел от `Telegram.Bot` (`InlineKeyboardMarkup`, `ParseMode.Html`). Это создавало проблемы:
|
||||
|
||||
1. **Shared не был platform-neutral.** Любой платформенный проект (Discord, Slack, WebSocket) тащил Telegram-зависимость.
|
||||
2. **Дублирование логики.** `GmRelay.Web` использовал тот же рендерер через прямую зависимость от `Shared`, но Web — это не Telegram-клиент.
|
||||
3. **Невозможно написать unit-тесты без Telegram-объектов.** Smoke-тесты создавали InlineKeyboardMarkup даже для проверки чисто доменной логики.
|
||||
|
||||
## Decision
|
||||
|
||||
Разделить рендеринг на две стадии:
|
||||
|
||||
1. **View Builder (platform-neutral)** — собирает view model из доменных DTO.
|
||||
2. **Platform Renderer (platform-specific)** — превращает view model в платформенное представление.
|
||||
|
||||
```
|
||||
Domain DTOs
|
||||
│
|
||||
▼
|
||||
SessionBatchViewBuilder (Shared)
|
||||
│
|
||||
▼
|
||||
SessionBatchViewModel (platform-neutral)
|
||||
│
|
||||
├──► TelegramSessionBatchRenderer ──► HTML + InlineKeyboardMarkup
|
||||
│
|
||||
└──► DiscordSessionBatchRenderer ──► Discord embeds + buttons
|
||||
```
|
||||
|
||||
### Изменённые компоненты
|
||||
|
||||
| Компонент | Было | Стало |
|
||||
|---|---|---|
|
||||
| `SessionBatchRenderer` | `GmRelay.Shared.Rendering` | Удалён |
|
||||
| `SessionBatchViewBuilder` | — | `GmRelay.Shared.Rendering` |
|
||||
| `SessionBatchViewModel` | — | `GmRelay.Shared.Rendering` |
|
||||
| `TelegramSessionBatchRenderer` | — | `GmRelay.Bot` + `GmRelay.Web` |
|
||||
| `DiscordSessionBatchRenderer` | — | `GmRelay.DiscordBot.Rendering` |
|
||||
| `BatchMessageEditor` | `GmRelay.Shared.Rendering` | `GmRelay.Bot` + `GmRelay.Web` |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- `GmRelay.Shared` больше не зависит от `Telegram.Bot`. Чистый platform-agnostic проект.
|
||||
- Discord renderer lives in `GmRelay.DiscordBot`, so NetCord stays out of `Shared`.
|
||||
- Unit-тесты ViewBuilder не создают `InlineKeyboardMarkup`.
|
||||
- Логика подсчёта игроков, сортировки сессий и генерации действий — в одном месте (ViewBuilder).
|
||||
|
||||
### Negative
|
||||
|
||||
- **Временное дублирование.** `TelegramSessionBatchRenderer` и `BatchMessageEditor` скопированы в `Bot` и `Web`. Планируется вынести в `GmRelay.Shared.Telegram` при появлении третьего Telegram-потребителя.
|
||||
- **Дополнительная стадия.** Теперь два вызова вместо одного: `Build` + `Render`. Этоtrade-off за чистоту абстракции.
|
||||
|
||||
## Related
|
||||
|
||||
- Issue #22 — этот рефакторинг.
|
||||
- Issue #26 — Discord Bot MVP (потребитель новой архитектуры).
|
||||
- Issue #30 — Discord reschedule voting использует `IPlatformMessenger`.
|
||||
- Issue #31 — scheduler notifications и reschedule deadline updates через `IPlatformMessenger`.
|
||||
- Issue #32 — compose wiring для Discord bot (healthcheck :8082).
|
||||
- Issue #33 — регрессионные тесты platform rendering (Telegram + Discord).
|
||||
- ADR 001 — vertical slice, native AOT, Aspire (`docs/adr/0001-use-vertical-slice-native-aot-and-aspire.md`).
|
||||
@@ -0,0 +1,57 @@
|
||||
# ADR 003: Discord Integration Architecture
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted** — implemented in v2.6.0 (PR #87, issue #30).
|
||||
|
||||
## Context
|
||||
|
||||
После Telegram-бота требовалась поддержка Discord для кросс-платформенных групп. Нужно было выбрать:
|
||||
1. Библиотеку для Discord API (NetCord vs DSharpPlus vs Discord.NET).
|
||||
2. Модель runtime (отдельный процесс vs тот же Worker).
|
||||
3. Способ обработки интеракций (Gateway events vs HTTP interactions).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. NetCord (не DSharpPlus, не Discord.NET)
|
||||
|
||||
- **NetCord** — лёгкий, AOT-compatible, minimal dependencies.
|
||||
- **DSharpPlus** — слишком тяжёлый, много зависимостей, reflection-heavy.
|
||||
- **Discord.NET** — несовместим с Native AOT (heavy reflection, dynamic IL).
|
||||
|
||||
### 2. Gateway Events внутри GmRelay.Bot
|
||||
|
||||
- Discord Gateway worker живёт **внутри** `GmRelay.Bot` (тот же Worker Service), а не как отдельный проект.
|
||||
- Это упрощает DI, shared DB connection, shared `IPlatformMessenger`.
|
||||
- Для масштабирования можно вынести в отдельный контейнер позже.
|
||||
|
||||
### 3. Slash-команды через NetCord ApplicationCommandService
|
||||
|
||||
- Регистрация глобальных slash-команд (`/newsession`, `/listsessions`) через `ApplicationCommandService`.
|
||||
- Команды мапятся на vertical slice handlers через `DiscordSessionInteractionModule`.
|
||||
|
||||
### 4. Ephemeral Replies
|
||||
|
||||
- Все кнопки (Join/Leave/RSVP) отвечают ephemeral (`MessageFlags.Ephemeral`).
|
||||
- Schedule message редактируется через `DiscordPlatformMessenger` (реализация `IPlatformMessenger`).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Один бинарник для Telegram + Discord.
|
||||
- Shared DI, shared DB pool, shared domain logic.
|
||||
- Native AOT совместимость.
|
||||
|
||||
### Negative
|
||||
|
||||
- Gateway connection требует persistent WebSocket — при разрыве происходит reconnect.
|
||||
- Discord rate limits агрессивнее Telegram — нужен backoff.
|
||||
|
||||
## Related
|
||||
|
||||
- Issue #30 — reschedule voting (кнопки + дедлайн).
|
||||
- Issue #31 — scheduler notifications через `IPlatformMessenger`.
|
||||
- Issue #32 — compose wiring + healthcheck.
|
||||
- ADR 001 — Vertical Slice, Native AOT, Aspire.
|
||||
- ADR 002 — Platform-Neutral Rendering.
|
||||
+88
-39
@@ -1,4 +1,4 @@
|
||||
# GM-Relay — C4 Model
|
||||
# GM-Relay - C4 Model
|
||||
|
||||
## Level 1: System Context
|
||||
|
||||
@@ -6,19 +6,26 @@
|
||||
C4Context
|
||||
title GM-Relay System Context
|
||||
|
||||
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр")
|
||||
Person(player, "Player", "Подтверждает участие через inline-кнопки")
|
||||
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
||||
Person(visitor, "Public visitor", "Views published club schedules without private player data")
|
||||
|
||||
System(gmrelay, "GM-Relay Bot", "Telegram Worker Service на Raspberry Pi. Управляет подтверждениями, рассылает напоминания и ссылки.")
|
||||
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club pages, and shared scheduling logic")
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API", "Long Polling. Сообщения, inline keyboards, callback queries.")
|
||||
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы")
|
||||
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
|
||||
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
|
||||
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
|
||||
|
||||
Rel(gm, telegram, "Команды бота (/newsession)")
|
||||
Rel(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)")
|
||||
Rel(telegram, gmrelay, "Updates (Long Polling)")
|
||||
Rel(gm, telegram, "Creates and manages sessions")
|
||||
Rel(gm, discord, "Uses /newsession and /listsessions")
|
||||
Rel(player, telegram, "Uses inline buttons")
|
||||
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
|
||||
Rel(visitor, gmrelay, "Views public club and session pages")
|
||||
Rel(telegram, gmrelay, "Updates via long polling")
|
||||
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
||||
Rel(gmrelay, postgres, "SQL (Npgsql + Dapper)")
|
||||
Rel(gmrelay, discord, "Send/edit schedule, RSVP, reminder, and reschedule messages")
|
||||
Rel(gmrelay, postgres, "SQL via Npgsql and Dapper")
|
||||
```
|
||||
|
||||
## Level 2: Container
|
||||
@@ -29,50 +36,92 @@ C4Container
|
||||
|
||||
Person(gm, "Game Master")
|
||||
Person(player, "Player")
|
||||
Person(visitor, "Public visitor")
|
||||
|
||||
System_Boundary(pi, "Raspberry Pi 5") {
|
||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Long polling, обработка команд и callback queries, планировщик")
|
||||
ContainerDb(db, "PostgreSQL 16", "Database", "sessions, players, session_participants, game_groups")
|
||||
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
||||
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
|
||||
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club pages, editing and stats")
|
||||
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
|
||||
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, platform identities")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
System_Ext(discord, "Discord Gateway and REST API")
|
||||
|
||||
Rel(gm, telegram, "Commands")
|
||||
Rel(player, telegram, "Callback Queries")
|
||||
Rel(telegram, bot, "GetUpdates (Long Polling)")
|
||||
Rel(gm, discord, "Slash commands")
|
||||
Rel(player, telegram, "Callback queries")
|
||||
Rel(player, discord, "Button interactions")
|
||||
Rel(visitor, web, "Read-only public schedule pages")
|
||||
Rel(telegram, bot, "GetUpdates")
|
||||
Rel(discord, discordBot, "Gateway events")
|
||||
Rel(bot, telegram, "Bot API calls")
|
||||
Rel(discordBot, discord, "REST send/edit/reply calls")
|
||||
Rel(bot, shared, "Uses shared renderers and join/leave handlers")
|
||||
Rel(discordBot, shared, "Uses shared renderers, scheduler, and platform-neutral handlers")
|
||||
Rel(web, shared, "Uses shared domain and rendering models")
|
||||
Rel(bot, db, "Npgsql + Dapper.AOT")
|
||||
Rel(discordBot, db, "Npgsql + Dapper")
|
||||
Rel(web, db, "Npgsql + Dapper")
|
||||
```
|
||||
|
||||
## Level 3: Component (GmRelay.Bot)
|
||||
## Level 3: Component - Session Interactions
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title GmRelay.Bot Components
|
||||
title Platform-Neutral Session Interactions
|
||||
|
||||
Container_Boundary(bot, "GmRelay.Bot") {
|
||||
Component(polling, "TelegramBotService", "BackgroundService", "Long polling loop, получает Updates")
|
||||
Component(router, "UpdateRouter", "C#", "Маршрутизирует Update → Handler по типу")
|
||||
Component(scheduler, "SessionSchedulerService", "BackgroundService", "PeriodicTimer(60s): T-24ч и T-5мин триггеры")
|
||||
Component(migrator, "DbMigrator", "DbUp", "SQL миграции при старте")
|
||||
|
||||
Component(confirm, "SendConfirmationHandler", "Feature", "Отправляет inline keyboard за 24ч")
|
||||
Component(rsvp, "HandleRsvpHandler", "Feature", "Обрабатывает ✅/❌, проверяет all-confirmed")
|
||||
Component(link, "SendJoinLinkHandler", "Feature", "Отправляет join link за 5 мин")
|
||||
Container_Boundary(shared, "GmRelay.Shared") {
|
||||
Component(join, "JoinSessionHandler", "Feature handler", "Adds players as Active or Waitlisted with session row locking")
|
||||
Component(leave, "LeaveSessionHandler", "Feature handler", "Removes players and promotes the first waitlisted player when capacity allows")
|
||||
Component(rsvp, "HandleRsvpHandler", "Feature handler", "Updates RSVP state and emits platform-neutral RSVP outcomes")
|
||||
Component(scheduler, "SessionSchedulerService", "Background service", "Triggers confirmation, reminder, and join-link notifications per platform")
|
||||
Component(updateLock, "ScheduleMessageUpdateLock", "In-memory keyed lock", "Serializes DB changes and schedule message edits per platform message")
|
||||
Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions")
|
||||
}
|
||||
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
Component(healthCheck, "DiscordHealthCheckHostedService", ":8082", "Healthcheck для Docker Compose")
|
||||
|
||||
Rel(polling, router, "Update")
|
||||
Rel(router, rsvp, "CallbackQuery rsvp:*")
|
||||
Rel(scheduler, confirm, "T-24h trigger")
|
||||
Rel(scheduler, link, "T-5min trigger")
|
||||
Rel(confirm, telegram, "SendMessage + InlineKeyboard")
|
||||
Rel(rsvp, telegram, "EditMessage + AnswerCallback")
|
||||
Rel(link, telegram, "SendMessage + user mentions")
|
||||
Rel(confirm, db, "SELECT/UPDATE sessions")
|
||||
Rel(rsvp, db, "UPDATE participants, SELECT counts")
|
||||
Rel(link, db, "SELECT confirmed players")
|
||||
Rel(migrator, db, "DDL migrations")
|
||||
Container_Boundary(discordBot, "GmRelay.DiscordBot") {
|
||||
Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session/rsvp buttons to neutral commands")
|
||||
Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Sends and edits Discord schedule, RSVP, reminder, join-link, and reschedule messages")
|
||||
}
|
||||
|
||||
Container_Boundary(bot, "GmRelay.Bot") {
|
||||
Component(updateRouter, "UpdateRouter", "Telegram adapter", "Maps callback queries to neutral commands")
|
||||
Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Sends and edits Telegram schedule, RSVP, reminder, join-link, and reschedule messages")
|
||||
}
|
||||
|
||||
ContainerDb(db, "PostgreSQL")
|
||||
System_Ext(telegram, "Telegram Bot API")
|
||||
System_Ext(discord, "Discord Gateway and REST API")
|
||||
|
||||
Rel(discord, discordModule, "Button interaction")
|
||||
Rel(discordModule, join, "JoinSessionCommand")
|
||||
Rel(discordModule, leave, "LeaveSessionCommand")
|
||||
Rel(discordModule, rsvp, "HandleRsvpCommand")
|
||||
Rel(discordModule, discord, "Deferred ephemeral reply, then modify response")
|
||||
Rel(updateRouter, join, "JoinSessionCommand")
|
||||
Rel(updateRouter, leave, "LeaveSessionCommand")
|
||||
Rel(updateRouter, rsvp, "HandleRsvpCommand")
|
||||
Rel(join, updateLock, "Acquire by PlatformMessageRef")
|
||||
Rel(leave, updateLock, "Acquire by PlatformMessageRef")
|
||||
Rel(join, db, "SELECT FOR UPDATE, INSERT participant")
|
||||
Rel(leave, db, "SELECT FOR UPDATE, DELETE/promote participant")
|
||||
Rel(rsvp, db, "Update RSVP and load notification recipients")
|
||||
Rel(scheduler, db, "Load due session triggers")
|
||||
Rel(join, renderer, "Build updated schedule view")
|
||||
Rel(leave, renderer, "Build updated schedule view")
|
||||
Rel(join, discordMessenger, "Update Discord schedule when command is Discord")
|
||||
Rel(leave, discordMessenger, "Update Discord schedule when command is Discord")
|
||||
Rel(join, telegramMessenger, "Update Telegram schedule when command is Telegram")
|
||||
Rel(leave, telegramMessenger, "Update Telegram schedule when command is Telegram")
|
||||
Rel(rsvp, discordMessenger, "Update Discord confirmation and outcomes")
|
||||
Rel(rsvp, telegramMessenger, "Update Telegram confirmation and outcomes")
|
||||
Rel(scheduler, discordMessenger, "Send Discord scheduler notifications")
|
||||
Rel(scheduler, telegramMessenger, "Send Telegram scheduler notifications")
|
||||
Rel(discordMessenger, discord, "REST send/edit/DM + ephemeral text")
|
||||
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
|
||||
Rel(healthCheck, discord, "HTTP /health")
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
# Game Catalog and One-Shot Showcase — Design Spec
|
||||
|
||||
> Issue #39: feat: добавить каталог игр и витрину ваншотов
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
|
||||
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
|
||||
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### New Fields on `sessions` (Migration V027)
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---|---|---|---|
|
||||
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
|
||||
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
|
||||
| `description` | `TEXT` | nullable | Short description for card |
|
||||
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
|
||||
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
|
||||
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
|
||||
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |
|
||||
|
||||
### `GameSystem` Enum
|
||||
|
||||
```csharp
|
||||
public enum GameSystem
|
||||
{
|
||||
Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
|
||||
OldSchoolEssentials, Dragonbane, BladesInTheDark,
|
||||
Daggerheart, CyberpunkRed, Mothership, AlienRpg,
|
||||
WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
|
||||
Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
|
||||
Ironsworn, Other
|
||||
}
|
||||
```
|
||||
|
||||
Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.
|
||||
|
||||
### DTOs
|
||||
|
||||
```csharp
|
||||
public sealed record ShowcaseSessionDto(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
string? System,
|
||||
bool IsOneShot,
|
||||
string? Format,
|
||||
int? DurationMinutes,
|
||||
string? CoverImageUrl,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount,
|
||||
bool AllowDirectRegistration);
|
||||
|
||||
public sealed record ShowcaseFilter(
|
||||
DateFilter Date = DateFilter.All,
|
||||
SeatFilter Seats = SeatFilter.Any,
|
||||
GameSystem? System = null,
|
||||
bool? IsOneShot = null,
|
||||
string? Format = null);
|
||||
|
||||
public enum DateFilter { Today, Tomorrow, ThisWeek, All }
|
||||
public enum SeatFilter { Available, Waitlist, Any }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### `/showcase` — Catalog Page
|
||||
|
||||
**Layout:**
|
||||
- Hero with title "Каталог игр"
|
||||
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
|
||||
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
|
||||
- Pagination (page + pageSize = 12)
|
||||
|
||||
**Filters:**
|
||||
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
|
||||
- Seats: "Есть места" | "Waitlist" | "Любое"
|
||||
- System: dropdown with all `GameSystem` values
|
||||
- Type: "Ваншот" | "Кампания" | "Любое"
|
||||
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"
|
||||
|
||||
**Card Design:**
|
||||
- Cover image (fallback: colored placeholder with initials)
|
||||
- Title
|
||||
- System badge
|
||||
- Date + time (MSK)
|
||||
- Duration (e.g. "3 часа")
|
||||
- Format badge
|
||||
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
|
||||
- Club name (link to `/club/{slug}`)
|
||||
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)
|
||||
|
||||
### `/s/{id}` — Public Session Detail (Updated)
|
||||
|
||||
New fields added to existing page:
|
||||
- Cover image (full-width hero)
|
||||
- System badge
|
||||
- Description block
|
||||
- Duration + format
|
||||
- GM contact (always visible: Telegram username or Discord tag)
|
||||
- If `allow_direct_registration`:
|
||||
- "Записаться" button → Telegram Mini App deeplink or Discord OAuth
|
||||
- Direct registration into `session_participants` via `SessionCapacityRules`
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### ISessionStore Methods
|
||||
|
||||
```csharp
|
||||
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
|
||||
ShowcaseFilter filter, int page, int pageSize);
|
||||
|
||||
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||
|
||||
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
|
||||
```
|
||||
|
||||
`GetShowcaseSessionsAsync` query:
|
||||
- Cross-group (all clubs with `public_schedule_enabled = true`)
|
||||
- Only `is_public = true` sessions
|
||||
- `scheduled_at > now() - interval '4 hours'`
|
||||
- `status <> 'Cancelled'`
|
||||
- Apply filters in SQL WHERE clause
|
||||
- Order by `scheduled_at ASC`
|
||||
- Offset/limit pagination
|
||||
|
||||
`RegisterFromShowcaseAsync`:
|
||||
- Check `allow_direct_registration = true`
|
||||
- Load session with `FOR UPDATE`
|
||||
- Count active + waitlisted participants
|
||||
- Use `SessionCapacityRules.DecideJoinStatus`
|
||||
- Insert participant with appropriate `registration_status`
|
||||
- Return true on success, false if full and no waitlist allowed
|
||||
|
||||
---
|
||||
|
||||
## Bot Integration
|
||||
|
||||
### Telegram Bot
|
||||
|
||||
During `CreateSessionCommand` flow, after title/link/time input:
|
||||
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
|
||||
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
|
||||
3. "Описание игры (краткое):" — text input, optional (skip button)
|
||||
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
|
||||
5. "Продолжительность (в часах):" — int input, optional
|
||||
6. "Обложка (URL или пропустить):" — text input, optional
|
||||
|
||||
During `/publish` flow:
|
||||
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)
|
||||
|
||||
### Discord Bot
|
||||
|
||||
Same flow adapted for Discord interactions:
|
||||
- Slash command options or button menus for system/format
|
||||
- Modal input for description, duration, cover URL
|
||||
- Fuzzy matching on free-text system input
|
||||
|
||||
---
|
||||
|
||||
## Migration V027
|
||||
|
||||
```sql
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN system VARCHAR(50),
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN cover_image_url TEXT,
|
||||
ADD COLUMN duration_minutes INTEGER,
|
||||
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
|
||||
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX ix_sessions_showcase
|
||||
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||
WHERE is_public = true AND status <> 'Cancelled';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
|
||||
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
|
||||
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
|
||||
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution
|
||||
|
||||
---
|
||||
|
||||
## Version Bump
|
||||
|
||||
Issue label: `type:feature` → **minor bump**
|
||||
Current: `3.3.0` → Next: `3.4.0`
|
||||
|
||||
Files to sync:
|
||||
- `Directory.Build.props`
|
||||
- `compose.yaml` (bot, discord, web image tags)
|
||||
- `.gitea/workflows/deploy.yml` (`VERSION` env)
|
||||
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (from Issue #39)
|
||||
|
||||
- [ ] User can find a published game without accessing a private dashboard
|
||||
- [ ] Registration does not bypass existing seat/waitlist limits
|
||||
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
|
||||
- [ ] Filters work: date, seats, system, type, format
|
||||
- [ ] GM contact is always visible on public session detail
|
||||
- [ ] Direct registration respects `SessionCapacityRules`
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# GM-Relay PostgreSQL Backup Restore Script
|
||||
# Usage: ./scripts/restore.sh [backup_file]
|
||||
# If no file is provided, uses the most recent backup.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# Check required env
|
||||
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||
if [ -f "${PROJECT_ROOT}/.env" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
set -a
|
||||
. "${PROJECT_ROOT}/.env"
|
||||
set +a
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||
echo "ERROR: POSTGRES_PASSWORD is not set. Please set it in your environment or .env file."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_DIR="${PROJECT_ROOT}/backups"
|
||||
|
||||
# Determine backup file
|
||||
if [ $# -ge 1 ]; then
|
||||
BACKUP_FILE="$1"
|
||||
else
|
||||
BACKUP_FILE=$(find "${BACKUP_DIR}" -name 'gmrelay_db_*.sql.gz' -type f -printf '%T+ %p\n' 2>/dev/null | sort -r | head -n1 | cut -d' ' -f2-)
|
||||
if [ -z "${BACKUP_FILE}" ]; then
|
||||
echo "ERROR: No backup files found in ${BACKUP_DIR}."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "${BACKUP_FILE}" ]; then
|
||||
echo "ERROR: Backup file not found: ${BACKUP_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=================================================="
|
||||
echo " GM-Relay PostgreSQL Restore"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
echo "Backup file: ${BACKUP_FILE}"
|
||||
echo "Database: gmrelay_db"
|
||||
echo "User: gmrelay"
|
||||
echo ""
|
||||
read -p "This will OVERWRITE the current database. Are you sure? [y/N] " CONFIRM
|
||||
|
||||
if [[ ! "${CONFIRM}" =~ ^[Yy]$ ]]; then
|
||||
echo "Restore cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Restoring database from ${BACKUP_FILE}..."
|
||||
|
||||
# Restore using docker compose exec to leverage the running postgres container
|
||||
COMPOSE_ARGS="-f ${PROJECT_ROOT}/compose.yaml"
|
||||
|
||||
docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \
|
||||
-U gmrelay \
|
||||
-d gmrelay_db \
|
||||
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" 2>/dev/null || true
|
||||
|
||||
gunzip -c "${BACKUP_FILE}" | docker compose ${COMPOSE_ARGS} exec -T -e PGPASSWORD="${POSTGRES_PASSWORD}" db psql \
|
||||
-U gmrelay \
|
||||
-d gmrelay_db
|
||||
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo " Restore completed successfully!"
|
||||
echo "=================================================="
|
||||
@@ -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,687 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Aspire.Dashboard.Sdk.win-x64": {
|
||||
"type": "Direct",
|
||||
"requested": "[13.2.1, )",
|
||||
"resolved": "13.2.1",
|
||||
"contentHash": "KLB9rXwY8kg2taWwxsJFoK0cAuupSZurcv1zTyYMqLyNuwvYYjs65Yz3g/cgh22QlUfOT3tOh+Jzk5MdJhy5+w=="
|
||||
},
|
||||
"Aspire.Hosting.AppHost": {
|
||||
"type": "Direct",
|
||||
"requested": "[13.2.1, )",
|
||||
"resolved": "13.2.1",
|
||||
"contentHash": "4B/eoZPwOobxpMpvYnqe/EcXabjPhZJhfxlHXv5gdKd16duoWbHnvvAZJsVI3WUpakCwmsCiTrT4sNGfW8H+IQ==",
|
||||
"dependencies": {
|
||||
"AspNetCore.HealthChecks.Uris": "9.0.0",
|
||||
"Aspire.Hosting": "13.2.1",
|
||||
"Google.Protobuf": "3.33.5",
|
||||
"Grpc.AspNetCore": "2.76.0",
|
||||
"Grpc.Net.ClientFactory": "2.76.0",
|
||||
"Grpc.Tools": "2.78.0",
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"JsonPatch.Net": "3.3.0",
|
||||
"KubernetesClient": "18.0.13",
|
||||
"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.FileSystemGlobbing": "10.0.5",
|
||||
"Microsoft.Extensions.Hosting": "10.0.5",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Http": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5",
|
||||
"ModelContextProtocol": "1.0.0",
|
||||
"Newtonsoft.Json": "13.0.4",
|
||||
"Polly.Core": "8.6.5",
|
||||
"Semver": "3.0.0",
|
||||
"StreamJsonRpc": "2.22.23",
|
||||
"System.IO.Hashing": "10.0.3"
|
||||
}
|
||||
},
|
||||
"Aspire.Hosting.Orchestration.win-x64": {
|
||||
"type": "Direct",
|
||||
"requested": "[13.2.1, )",
|
||||
"resolved": "13.2.1",
|
||||
"contentHash": "39lRUH4WuCsBaYB7fZH1/r81SSJIXrA8WphBlAdP1QT95+1sKQHzXJuXU4nzKpBLv4oZmjcWzvA+FDMGZbWmkw=="
|
||||
},
|
||||
"Aspire.Hosting.PostgreSQL": {
|
||||
"type": "Direct",
|
||||
"requested": "[13.2.1, )",
|
||||
"resolved": "13.2.1",
|
||||
"contentHash": "7F/nmeplR9cYE/B/E1haRjnkoBRQ/voMXpnK/SNJoXSFs4Vb/g00CDDvI/xfH3SAV7Xq8ekWa9ZbX56JuQ+YiA==",
|
||||
"dependencies": {
|
||||
"AspNetCore.HealthChecks.NpgSql": "9.0.0",
|
||||
"AspNetCore.HealthChecks.Uris": "9.0.0",
|
||||
"Aspire.Hosting": "13.2.1",
|
||||
"Google.Protobuf": "3.33.5",
|
||||
"Grpc.AspNetCore": "2.76.0",
|
||||
"Grpc.Net.ClientFactory": "2.76.0",
|
||||
"Grpc.Tools": "2.78.0",
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"JsonPatch.Net": "3.3.0",
|
||||
"KubernetesClient": "18.0.13",
|
||||
"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": "8.0.25",
|
||||
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
|
||||
"Microsoft.Extensions.Hosting": "10.0.5",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Http": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5",
|
||||
"ModelContextProtocol": "1.0.0",
|
||||
"Newtonsoft.Json": "13.0.4",
|
||||
"Polly.Core": "8.6.5",
|
||||
"Semver": "3.0.0",
|
||||
"StreamJsonRpc": "2.22.23",
|
||||
"System.IO.Hashing": "10.0.3"
|
||||
}
|
||||
},
|
||||
"SecurityCodeScan.VS2019": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.7, )",
|
||||
"resolved": "5.6.7",
|
||||
"contentHash": "WIE9RJswdSc2j+rLz2gW6U+gMUjMHzY2j7C/CL8/R2olXNM/+twarfMnWqm+rZodDBvaYDApJyxM8mVYf9FGrQ=="
|
||||
},
|
||||
"Aspire.Hosting": {
|
||||
"type": "Transitive",
|
||||
"resolved": "13.2.1",
|
||||
"contentHash": "GY/T5iK2F4K3Sk60VUeVnTX1MhCjSaX48+qPUjA/rI1x1ONHevHzFj+Gc3fNlGEaZGY8L87hSxwGrV+Bjd5EJw==",
|
||||
"dependencies": {
|
||||
"AspNetCore.HealthChecks.Uris": "9.0.0",
|
||||
"Google.Protobuf": "3.33.5",
|
||||
"Grpc.AspNetCore": "2.76.0",
|
||||
"Grpc.Net.ClientFactory": "2.76.0",
|
||||
"Grpc.Tools": "2.78.0",
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"JsonPatch.Net": "3.3.0",
|
||||
"KubernetesClient": "18.0.13",
|
||||
"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": "8.0.25",
|
||||
"Microsoft.Extensions.FileSystemGlobbing": "10.0.5",
|
||||
"Microsoft.Extensions.Hosting": "10.0.5",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Http": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5",
|
||||
"ModelContextProtocol": "1.0.0",
|
||||
"Newtonsoft.Json": "13.0.4",
|
||||
"Polly.Core": "8.6.5",
|
||||
"Semver": "3.0.0",
|
||||
"StreamJsonRpc": "2.22.23",
|
||||
"System.IO.Hashing": "10.0.3"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"AspNetCore.HealthChecks.Uris": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "XYdNlA437KeF8p9qOpZFyNqAN+c0FXt/JjTvzH/Qans0q0O3pPE8KPnn39ucQQjR/Roum1vLTP3kXiUs8VHyuA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11",
|
||||
"Microsoft.Extensions.Http": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Fractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "7.3.0",
|
||||
"contentHash": "2bETFWLBc8b7Ut2SVi+bxhGVwiSpknHYGBh2PADyGWONLkTxT7bKyDRhF8ao+XUv90tq8Fl7GTPxSI5bacIRJw=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.33.5",
|
||||
"contentHash": "XEzLpCTosZb5I6eGSPn7rAES0VfkJkn3Cqydh0W39POdZwkdhPhOmAROTFJF9g0ardst4ulNXRm/q/iXwNu+Qw=="
|
||||
},
|
||||
"Grpc.AspNetCore": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.76.0",
|
||||
"contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "3.31.1",
|
||||
"Grpc.AspNetCore.Server.ClientFactory": "2.76.0",
|
||||
"Grpc.Tools": "2.76.0"
|
||||
}
|
||||
},
|
||||
"Grpc.AspNetCore.Server": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.76.0",
|
||||
"contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==",
|
||||
"dependencies": {
|
||||
"Grpc.Net.Common": "2.76.0"
|
||||
}
|
||||
},
|
||||
"Grpc.AspNetCore.Server.ClientFactory": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.76.0",
|
||||
"contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==",
|
||||
"dependencies": {
|
||||
"Grpc.AspNetCore.Server": "2.76.0",
|
||||
"Grpc.Net.ClientFactory": "2.76.0"
|
||||
}
|
||||
},
|
||||
"Grpc.Core.Api": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.76.0",
|
||||
"contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ=="
|
||||
},
|
||||
"Grpc.Net.Client": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.76.0",
|
||||
"contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==",
|
||||
"dependencies": {
|
||||
"Grpc.Net.Common": "2.76.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Grpc.Net.ClientFactory": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.76.0",
|
||||
"contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==",
|
||||
"dependencies": {
|
||||
"Grpc.Net.Client": "2.76.0",
|
||||
"Microsoft.Extensions.Http": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Grpc.Net.Common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.76.0",
|
||||
"contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==",
|
||||
"dependencies": {
|
||||
"Grpc.Core.Api": "2.76.0"
|
||||
}
|
||||
},
|
||||
"Grpc.Tools": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.78.0",
|
||||
"contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg=="
|
||||
},
|
||||
"Humanizer.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.14.1",
|
||||
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
|
||||
},
|
||||
"Json.More.Net": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.0",
|
||||
"contentHash": "qtwsyAsL55y2vB2/sK4Pjg3ZyVzD5KKSpV3lOAMHlnjFfsjQ/86eHJfQT9aV1YysVXzF4+xyHOZbh7Iu3YQ7Lg=="
|
||||
},
|
||||
"JsonPatch.Net": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.3.0",
|
||||
"contentHash": "GIcMMDtzfzVfIpQgey8w7dhzcw6jG5nD4DDAdQCTmHfblkCvN7mI8K03to8YyUhKMl4PTR6D6nLSvWmyOGFNTg==",
|
||||
"dependencies": {
|
||||
"JsonPointer.Net": "5.2.0"
|
||||
}
|
||||
},
|
||||
"JsonPointer.Net": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.2.0",
|
||||
"contentHash": "qe1F7Tr/p4mgwLPU9P60MbYkp+xnL2uCPnWXGgzfR/AZCunAZIC0RZ32dLGJJEhSuLEfm0YF/1R3u5C7mEVq+w==",
|
||||
"dependencies": {
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"Json.More.Net": "2.1.0"
|
||||
}
|
||||
},
|
||||
"KubernetesClient": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.0.13",
|
||||
"contentHash": "X5IuxmydftB148XeULtc7rD5/RvqLuW5SzkIjFovPgJpvV4RAoRqNPruVB7GEFu1Xg+zHVIk88WqdV8JjbgHbA==",
|
||||
"dependencies": {
|
||||
"Fractions": "7.3.0",
|
||||
"YamlDotNet": "16.3.0"
|
||||
}
|
||||
},
|
||||
"MessagePack": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.5.192",
|
||||
"contentHash": "Jtle5MaFeIFkdXtxQeL9Tu2Y3HsAQGoSntOzrn6Br/jrl6c8QmG22GEioT5HBtZJR0zw0s46OnKU8ei2M3QifA==",
|
||||
"dependencies": {
|
||||
"MessagePack.Annotations": "2.5.192",
|
||||
"Microsoft.NET.StringTools": "17.6.3"
|
||||
}
|
||||
},
|
||||
"MessagePack.Annotations": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.5.192",
|
||||
"contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg=="
|
||||
},
|
||||
"Microsoft.Extensions.AI.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.3.0",
|
||||
"contentHash": "hDjDvUERvUH3HBMs2MDusOcGJBjAHOG5pJIU2x/HZEa4e1UthNKt89cwMi3B+ogJo6skki1XFjfgGN3ksnVqvQ=="
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Binder": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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.FileProviders.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"type": "Transitive",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Hosting.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Http": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "AiFvHYM8nP0wPC7bGPI3NHQlSYSLqjjT7DMJUuuxhd+7pz3O89iu2gdQfgACy5DxsXENiok5i1bMacJL7KR8jA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Diagnostics": "10.0.5",
|
||||
"Microsoft.Extensions.Logging": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
|
||||
},
|
||||
"Microsoft.NET.StringTools": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.6.3",
|
||||
"contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA=="
|
||||
},
|
||||
"Microsoft.VisualStudio.Threading.Only": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.13.61",
|
||||
"contentHash": "vl5a2URJYCO5m+aZZtNlAXAMz28e2pUotRuoHD7RnCWOCeoyd8hWp5ZBaLNYq4iEj2oeJx5ZxiSboAjVmB20Qg==",
|
||||
"dependencies": {
|
||||
"Microsoft.VisualStudio.Validation": "17.8.8"
|
||||
}
|
||||
},
|
||||
"Microsoft.VisualStudio.Validation": {
|
||||
"type": "Transitive",
|
||||
"resolved": "17.8.8",
|
||||
"contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g=="
|
||||
},
|
||||
"ModelContextProtocol": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.0",
|
||||
"contentHash": "W7UX8AQ1qMjXyCDcpP25u/L1W2vIIgfhLX/B2ZtTU1VUyILXdmVbdRjkQesKVPT/wPMpYXIHUcZJTPdsGfKSfQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Caching.Abstractions": "10.0.3",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.3",
|
||||
"ModelContextProtocol.Core": "1.0.0"
|
||||
}
|
||||
},
|
||||
"ModelContextProtocol.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.0",
|
||||
"contentHash": "QKboiQEq2MJMGeQ029Gy6xqge88abm0Px9lnG7hueOyf+EDCxi5SUATV+Df7GwT+NwWzkEsYG271bUQD+LGhEg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.AI.Abstractions": "10.3.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.3"
|
||||
}
|
||||
},
|
||||
"Nerdbank.Streams": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.12.87",
|
||||
"contentHash": "oDKOeKZ865I5X8qmU3IXMyrAnssYEiYWTobPGdrqubN3RtTzEHIv+D6fwhdcfrdhPJzHjCkK/ORztR/IsnmA6g==",
|
||||
"dependencies": {
|
||||
"Microsoft.VisualStudio.Threading.Only": "17.13.61",
|
||||
"Microsoft.VisualStudio.Validation": "17.8.8"
|
||||
}
|
||||
},
|
||||
"Newtonsoft.Json": {
|
||||
"type": "Transitive",
|
||||
"resolved": "13.0.4",
|
||||
"contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A=="
|
||||
},
|
||||
"Npgsql": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.3",
|
||||
"contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Polly.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.6.5",
|
||||
"contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg=="
|
||||
},
|
||||
"Semver": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.0.0",
|
||||
"contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "5.0.1"
|
||||
}
|
||||
},
|
||||
"StreamJsonRpc": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.22.23",
|
||||
"contentHash": "Ahq6uUFPnU9alny5h4agyX74th3PRq3NQCRNaDOqWcx20WT06mH/wENSk5IbHDc8BmfreQVEIBx5IXLBbsLFIA==",
|
||||
"dependencies": {
|
||||
"MessagePack": "2.5.192",
|
||||
"Microsoft.VisualStudio.Threading.Only": "17.13.61",
|
||||
"Microsoft.VisualStudio.Validation": "17.8.8",
|
||||
"Nerdbank.Streams": "2.12.87",
|
||||
"Newtonsoft.Json": "13.0.3"
|
||||
}
|
||||
},
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
|
||||
},
|
||||
"System.IO.Hashing": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "La6ICwsdTKhVX+LKN+pvFjQRR3LhLwq3uKdi2knjLzRyPYBSydF4cjXidYxIiTcDD6XVYdsBWQEI8ZxiZ/OdIg=="
|
||||
},
|
||||
"YamlDotNet": {
|
||||
"type": "Transitive",
|
||||
"resolved": "16.3.0",
|
||||
"contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,16 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем wget для healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем только AOT-результаты из билда
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
USER $APP_UID
|
||||
|
||||
# Запуск скомпилированного AOT бинарного файла напрямую
|
||||
ENTRYPOINT ["./GmRelay.Bot"]
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||
|
||||
public sealed record HandleRsvpCommand(
|
||||
Guid SessionId,
|
||||
long TelegramUserId,
|
||||
string Status,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
||||
|
||||
internal sealed record SessionContext(
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
long GmTelegramId,
|
||||
long TelegramChatId);
|
||||
|
||||
internal sealed record ParticipantRsvp(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername,
|
||||
string RsvpStatus);
|
||||
|
||||
public sealed class HandleRsvpHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<HandleRsvpHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var participantExists = await connection.ExecuteScalarAsync<bool>(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
)
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
|
||||
if (!participantExists)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: "Вы не являетесь участником этой сессии.",
|
||||
cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE session_participants
|
||||
SET rsvp_status = @Status,
|
||||
responded_at = now()
|
||||
WHERE session_id = @SessionId
|
||||
AND player_id = (SELECT id FROM players WHERE telegram_id = @TelegramUserId)
|
||||
AND registration_status = @Active
|
||||
AND rsvp_status != @Status
|
||||
""",
|
||||
new { command.SessionId, command.TelegramUserId, command.Status, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
|
||||
if (updated == 0)
|
||||
{
|
||||
var alreadyText = command.Status == RsvpStatus.Confirmed
|
||||
? "Вы уже подтвердили участие."
|
||||
: "Вы уже отказались от участия.";
|
||||
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: alreadyText,
|
||||
cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var session = await connection.QuerySingleAsync<SessionContext>(
|
||||
"""
|
||||
SELECT s.title,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.status AS Status,
|
||||
g.gm_telegram_id AS GmTelegramId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId },
|
||||
transaction);
|
||||
|
||||
if (command.Status == RsvpStatus.Declined)
|
||||
{
|
||||
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
|
||||
|
||||
if (decision.ShouldRevertSessionToConfirmationSent)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @ConfirmationSent, updated_at = now()
|
||||
WHERE id = @SessionId AND status = @Confirmed
|
||||
""",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
ConfirmationSent = SessionStatus.ConfirmationSent,
|
||||
Confirmed = SessionStatus.Confirmed
|
||||
},
|
||||
transaction);
|
||||
}
|
||||
|
||||
var declinedPlayer = await connection.QuerySingleAsync<string>(
|
||||
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
||||
new { command.TelegramUserId },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.GmTelegramId,
|
||||
text: $"🚨 Отмена! {declinedPlayer} не сможет прийти на игру «{session.Title}».",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: decision.CallbackText,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var counts = await connection.QuerySingleAsync<RsvpCounts>(
|
||||
"""
|
||||
SELECT
|
||||
count(*) AS Total,
|
||||
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
|
||||
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
|
||||
FROM session_participants
|
||||
WHERE session_id = @SessionId AND is_gm = false
|
||||
AND registration_status = @Active
|
||||
""",
|
||||
new
|
||||
{
|
||||
command.SessionId,
|
||||
Confirmed = RsvpStatus.Confirmed,
|
||||
Declined = RsvpStatus.Declined,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
},
|
||||
transaction);
|
||||
|
||||
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
|
||||
|
||||
if (decision.ShouldMarkSessionConfirmed)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @Confirmed, updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
|
||||
transaction);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
if (decision.ShouldNotifyGroup)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
text: $"🎉 Игра «{session.Title}» подтверждена! Все участники на месте.",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.ShouldNotifyGm)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: session.GmTelegramId,
|
||||
text: $"✅ Все подтвердили участие в «{session.Title}» ({session.ScheduledAt.FormatMoscow()} МСК).",
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(
|
||||
callbackQueryId: command.CallbackQueryId,
|
||||
text: decision.CallbackText,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
await UpdateConfirmationMessage(command, session, ct);
|
||||
}
|
||||
|
||||
private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantRsvp>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
sp.rsvp_status AS RsvpStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
ORDER BY sp.responded_at NULLS LAST
|
||||
""",
|
||||
new { command.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
|
||||
var confirmed = participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
|
||||
var declined = participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
|
||||
var pending = participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"🎲 Подтвердите участие в «{session.Title}»",
|
||||
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
|
||||
string.Empty
|
||||
};
|
||||
|
||||
foreach (var participant in confirmed)
|
||||
{
|
||||
lines.Add($" ✅ {FormatName(participant)}");
|
||||
}
|
||||
|
||||
foreach (var participant in declined)
|
||||
{
|
||||
lines.Add($" ❌ ~~{FormatName(participant)}~~");
|
||||
}
|
||||
|
||||
foreach (var participant in pending)
|
||||
{
|
||||
lines.Add($" ⏳ {FormatName(participant)}");
|
||||
}
|
||||
|
||||
lines.Add(string.Empty);
|
||||
|
||||
if (confirmed.Count == participants.Count)
|
||||
{
|
||||
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
|
||||
}
|
||||
else if (declined.Count > 0)
|
||||
{
|
||||
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
|
||||
}
|
||||
|
||||
var text = string.Join("\n", lines);
|
||||
|
||||
var replyMarkup = confirmed.Count == participants.Count
|
||||
? null
|
||||
: new InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{command.SessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{command.SessionId}")
|
||||
]
|
||||
]);
|
||||
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: text,
|
||||
replyMarkup: replyMarkup,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatName(ParticipantRsvp participant) =>
|
||||
participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
|
||||
// ── DTOs for Dapper mapping ──────────────────────────────────────────
|
||||
|
||||
internal sealed record SessionInfo(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
Guid GroupId,
|
||||
long TelegramChatId);
|
||||
|
||||
internal sealed record ParticipantInfo(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sends the interactive confirmation message (inline keyboard) to the group chat.
|
||||
/// Called by SessionSchedulerService at T-24h.
|
||||
/// </summary>
|
||||
public sealed class SendConfirmationHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<SendConfirmationHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Load session + group info
|
||||
var session = await connection.QuerySingleOrDefaultAsync<SessionInfo>(
|
||||
"""
|
||||
SELECT s.id, s.title, s.scheduled_at AS ScheduledAt, s.group_id AS GroupId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.status = @Planned
|
||||
""",
|
||||
new { SessionId = sessionId, Planned = SessionStatus.Planned });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} not found or not in Planned status", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Load non-GM participants
|
||||
var participants = (await connection.QueryAsync<ParticipantInfo>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
|
||||
if (participants.Count == 0)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} has no non-GM participants", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Build confirmation message
|
||||
var playerList = string.Join("\n", participants.Select(p =>
|
||||
$" ⏳ {FormatPlayerName(p)}"));
|
||||
|
||||
var text = $"""
|
||||
🎲 Подтвердите участие в «{session.Title}»
|
||||
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
|
||||
|
||||
{playerList}
|
||||
|
||||
Статус: ожидаем подтверждения (0/{participants.Count})
|
||||
""";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
|
||||
]
|
||||
]);
|
||||
|
||||
// 4. Send to group
|
||||
var message = await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
text: text,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
|
||||
// 5. Update session status and store message ID
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET status = @Status,
|
||||
confirmation_message_id = @MessageId,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Status = SessionStatus.ConfirmationSent,
|
||||
MessageId = message.MessageId
|
||||
});
|
||||
|
||||
logger.LogInformation(
|
||||
"Confirmation sent for session {SessionId} ({Title}), message_id={MessageId}",
|
||||
sessionId, session.Title, message.MessageId);
|
||||
}
|
||||
|
||||
internal static string FormatPlayerName(ParticipantInfo p) =>
|
||||
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Bot.Features.Notifications;
|
||||
|
||||
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
|
||||
|
||||
public sealed class DirectSessionNotificationSender(
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<DirectSessionNotificationSender> logger)
|
||||
{
|
||||
public async Task SendAsync(
|
||||
IEnumerable<DirectNotificationRecipient> recipients,
|
||||
string htmlText,
|
||||
string notificationKind,
|
||||
Guid sessionId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
try
|
||||
{
|
||||
await messenger.SendPrivateMessageAsync(
|
||||
new PlatformPrivateMessage(
|
||||
TelegramPlatformIds.User(recipient.TelegramId, recipient.DisplayName),
|
||||
htmlText),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Failed to send {NotificationKind} DM for session {SessionId} to player {TelegramId} ({DisplayName})",
|
||||
notificationKind,
|
||||
sessionId,
|
||||
recipient.TelegramId,
|
||||
recipient.DisplayName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
|
||||
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record JoinLinkSession(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string JoinLink,
|
||||
DateTime ScheduledAt,
|
||||
long TelegramChatId);
|
||||
|
||||
internal sealed record ConfirmedPlayer(
|
||||
long TelegramId,
|
||||
string DisplayName,
|
||||
string? TelegramUsername);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sends the join link to the group chat at T-5min, tagging all confirmed players.
|
||||
/// Called by SessionSchedulerService.
|
||||
/// </summary>
|
||||
public sealed class SendJoinLinkHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
ILogger<SendJoinLinkHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Load session
|
||||
var session = await connection.QuerySingleOrDefaultAsync<JoinLinkSession>(
|
||||
"""
|
||||
SELECT s.id, s.title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId
|
||||
AND s.status = @Confirmed
|
||||
AND s.link_message_id IS NULL
|
||||
""",
|
||||
new { SessionId = sessionId, Confirmed = SessionStatus.Confirmed });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} not eligible for join link", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Load confirmed players
|
||||
var players = (await connection.QueryAsync<ConfirmedPlayer>(
|
||||
"""
|
||||
SELECT p.telegram_id AS TelegramId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.rsvp_status = @Confirmed
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Confirmed = RsvpStatus.Confirmed,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
})).ToList();
|
||||
|
||||
// 3. Build message with player mentions
|
||||
var mentions = string.Join(", ", players.Select(p =>
|
||||
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName));
|
||||
|
||||
var text = $"""
|
||||
🎮 Игра «{session.Title}» начинается через 5 минут!
|
||||
|
||||
🔗 Ссылка на подключение:
|
||||
{session.JoinLink}
|
||||
|
||||
Участники: {mentions}
|
||||
|
||||
Хорошей игры! 🎲
|
||||
""";
|
||||
|
||||
// 4. Send
|
||||
var message = await bot.SendMessage(
|
||||
chatId: session.TelegramChatId,
|
||||
text: text,
|
||||
cancellationToken: ct);
|
||||
|
||||
// 5. Mark as sent (idempotent — link_message_id IS NULL guard in query)
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET link_message_id = @MessageId, updated_at = now()
|
||||
WHERE id = @SessionId AND link_message_id IS NULL
|
||||
""",
|
||||
new { SessionId = sessionId, MessageId = message.MessageId });
|
||||
|
||||
logger.LogInformation(
|
||||
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
|
||||
sessionId, session.Title, message.MessageId);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
@@ -12,38 +13,52 @@ public sealed record CancelSessionCommand(
|
||||
long TelegramUserId,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int? MessageThreadId,
|
||||
int MessageId);
|
||||
|
||||
// DTOs for AOT compilation
|
||||
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId);
|
||||
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, string NotificationMode);
|
||||
|
||||
public sealed class CancelSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
IPlatformMessenger messenger,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<CancelSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(CancelSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Проверяем, что запрос делает ГМ данной сессии
|
||||
|
||||
// 1. Проверяем, что запрос делает управляющий данной группы.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
|
||||
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId",
|
||||
new { command.SessionId }, transaction);
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
s.notification_mode AS NotificationMode,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может отменять сессию.", showAlert: true, cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может отменять сессию.", ct, showAlert: true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,7 +70,7 @@ public sealed class CancelSessionHandler(
|
||||
|
||||
// 3. Загружаем весь батч для перерисовки
|
||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers
|
||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
|
||||
FROM sessions
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at",
|
||||
@@ -73,30 +88,60 @@ public sealed class CancelSessionHandler(
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
||||
new { BatchId = session.BatchId }, transaction);
|
||||
|
||||
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||
"""
|
||||
SELECT p.external_user_id::BIGINT AS TelegramId,
|
||||
p.display_name AS DisplayName
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction)).ToList();
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// 4. Перерисовываем сообщение
|
||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: ct);
|
||||
var messageId = session.BatchMessageId ?? command.MessageId;
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
|
||||
view,
|
||||
TelegramPlatformIds.Message(command.ChatId, command.MessageThreadId, messageId)),
|
||||
ct);
|
||||
|
||||
await AnswerAsync(command.CallbackQueryId, "Сессия отменена!", ct);
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия отменена!", cancellationToken: ct);
|
||||
|
||||
// Опционально: написать отдельное сообщение в чат
|
||||
await bot.SendMessage(command.ChatId, $"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, cancellationToken: ct);
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
|
||||
$"❌ <b>Внимание!</b> Сессия \"{System.Net.WebUtility.HtmlEncode(session.Title)}\" отменена.",
|
||||
ct);
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
await directSender.SendAsync(
|
||||
directRecipients,
|
||||
$"❌ <b>Сессия отменена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>",
|
||||
"session-cancelled",
|
||||
command.SessionId,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Ошибка при обновлении сообщения.", cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Ошибка при обновлении сообщения.", ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
@@ -8,153 +9,186 @@ using Telegram.Bot.Types;
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed class CreateSessionHandler(
|
||||
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler,
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient botClient,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<CreateSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||
public async Task HandleAsync(Message message, CancellationToken ct)
|
||||
{
|
||||
var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
|
||||
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
|
||||
|
||||
foreach (var timeInput in parseResult.PastTimeInputs)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
message.Chat.Id,
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
||||
cancellationToken: cancellationToken);
|
||||
ct);
|
||||
}
|
||||
|
||||
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
message.Chat.Id,
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
||||
cancellationToken: cancellationToken);
|
||||
ct);
|
||||
}
|
||||
|
||||
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
message.Chat.Id,
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
|
||||
cancellationToken: cancellationToken);
|
||||
ct);
|
||||
}
|
||||
|
||||
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
|
||||
ct);
|
||||
}
|
||||
|
||||
if (!parseResult.IsValid)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link",
|
||||
cancellationToken: cancellationToken);
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
"""
|
||||
❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:
|
||||
|
||||
/newsession
|
||||
Название: My Game
|
||||
Время: 15.05.2026 19:30
|
||||
Время: 22.05.2026 19:30
|
||||
Мест: 4
|
||||
Ссылка: https://link
|
||||
Картинка: https://cover
|
||||
|
||||
Для повтора можно указать одну дату и строки:
|
||||
Игр: 4
|
||||
Интервал: 7
|
||||
""",
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var title = parseResult.Title!;
|
||||
var link = parseResult.Link!;
|
||||
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
|
||||
var gmId = message.From!.Id;
|
||||
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
|
||||
var gmUsername = message.From.Username;
|
||||
|
||||
var chatId = message.Chat.Id;
|
||||
var chatTitle = message.Chat.Title ?? "Private Chat";
|
||||
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
|
||||
message.Chat.IsForum,
|
||||
message.MessageThreadId);
|
||||
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
||||
var messageThreadId = topicDestination.MessageThreadId;
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
if (topicDestination.ShouldCreateForumTopic)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO players (telegram_id, display_name, telegram_username)
|
||||
VALUES (@TgId, @Name, @Username)
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
telegram_username = EXCLUDED.telegram_username;
|
||||
""",
|
||||
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
||||
transaction);
|
||||
|
||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
||||
VALUES (@ChatId, @ChatName, @GmId)
|
||||
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id;
|
||||
""",
|
||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||
transaction);
|
||||
|
||||
int? messageThreadId = null;
|
||||
if (message.Chat.IsForum)
|
||||
{
|
||||
var topic = await botClient.CreateForumTopic(
|
||||
chatId: chatId,
|
||||
name: $"🎲 Игры: {title}",
|
||||
cancellationToken: cancellationToken);
|
||||
messageThreadId = topic.MessageThreadId;
|
||||
}
|
||||
|
||||
var batchId = Guid.NewGuid();
|
||||
var sessions = new List<SessionBatchDto>();
|
||||
|
||||
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
|
||||
{
|
||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players)
|
||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @MaxPlayers)
|
||||
RETURNING id;
|
||||
""",
|
||||
new
|
||||
{
|
||||
BatchId = batchId,
|
||||
GroupId = groupId,
|
||||
Title = title,
|
||||
Link = link,
|
||||
ScheduledAt = scheduledAt,
|
||||
ThreadId = messageThreadId,
|
||||
MaxPlayers = parseResult.MaxPlayers,
|
||||
Status = SessionStatus.Planned
|
||||
},
|
||||
transaction);
|
||||
|
||||
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers));
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
||||
|
||||
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
|
||||
var batchMessage = await botClient.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: messageThreadId,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
||||
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
||||
|
||||
try
|
||||
{
|
||||
await botClient.DeleteMessage(
|
||||
chatId: chatId,
|
||||
messageId: message.MessageId,
|
||||
cancellationToken: cancellationToken);
|
||||
var topicRef = await messenger.CreateThreadAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
$"🎲 Игры: {parseResult.Title}",
|
||||
ct);
|
||||
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
when (ex.Message.Contains("not enough rights") ||
|
||||
ex.Message.Contains("CHAT_ADMIN_REQUIRED") ||
|
||||
ex.Message.Contains("not an administrator"))
|
||||
{
|
||||
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
TelegramTopicRouting.MissingForumTopicRightsMessage,
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat");
|
||||
var platformUser = new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
gmName,
|
||||
gmUsername);
|
||||
|
||||
var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand(
|
||||
platformUser,
|
||||
platformGroup,
|
||||
parseResult.Title!,
|
||||
parseResult.Link!,
|
||||
parseResult.ScheduledTimes,
|
||||
parseResult.MaxPlayers,
|
||||
imageReference);
|
||||
|
||||
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result;
|
||||
try
|
||||
{
|
||||
result = await sharedHandler.HandleAsync(command, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
"💥 Произошла ошибка базы данных при создании сессии.",
|
||||
ct);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(message.Chat.Id, null),
|
||||
result.ErrorMessage!,
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var scheduleMessage = new PlatformScheduleMessage(
|
||||
platformGroup,
|
||||
result.View!,
|
||||
null,
|
||||
imageReference);
|
||||
|
||||
var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct);
|
||||
|
||||
// Store batch_message_id
|
||||
if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId))
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
||||
new { MsgId = batchMessageId, BatchId = result.BatchId });
|
||||
}
|
||||
|
||||
// Delete original message
|
||||
try
|
||||
{
|
||||
await messenger.DeleteMessageAsync(
|
||||
TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка при создании сессии");
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
|
||||
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? GetBatchImageReference(Message message, string? parsedImageUrl)
|
||||
{
|
||||
var attachedPhotoFileId = message.Photo?
|
||||
.OrderByDescending(photo => photo.FileSize ?? 0)
|
||||
.ThenByDescending(photo => photo.Width * photo.Height)
|
||||
.FirstOrDefault()
|
||||
?.FileId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachedPhotoFileId))
|
||||
{
|
||||
return attachedPhotoFileId;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,40 +5,66 @@ namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
internal sealed record NewSessionParseResult(
|
||||
string? Title,
|
||||
string? Link,
|
||||
string? ImageUrl,
|
||||
int? MaxPlayers,
|
||||
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||
IReadOnlyList<string> PastTimeInputs,
|
||||
IReadOnlyList<string> InvalidTimeInputs,
|
||||
IReadOnlyList<string> InvalidSeatLimitInputs)
|
||||
IReadOnlyList<string> InvalidSeatLimitInputs,
|
||||
IReadOnlyList<string> InvalidRecurringInputs)
|
||||
{
|
||||
public bool IsValid =>
|
||||
!string.IsNullOrWhiteSpace(Title) &&
|
||||
!string.IsNullOrWhiteSpace(Link) &&
|
||||
ScheduledTimes.Count > 0 &&
|
||||
InvalidSeatLimitInputs.Count == 0;
|
||||
InvalidSeatLimitInputs.Count == 0 &&
|
||||
InvalidRecurringInputs.Count == 0;
|
||||
}
|
||||
|
||||
internal static class NewSessionCommandParser
|
||||
{
|
||||
private const int MaxRecurringSessionCount = 52;
|
||||
private const int MaxRecurringIntervalDays = 365;
|
||||
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
|
||||
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
|
||||
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
|
||||
private static readonly string[] ImagePrefixes =
|
||||
[
|
||||
"\u041a\u0430\u0440\u0442\u0438\u043d\u043a\u0430:",
|
||||
"\u0418\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435:",
|
||||
"\u041e\u0431\u043b\u043e\u0436\u043a\u0430:"
|
||||
];
|
||||
private static readonly string[] SeatLimitPrefixes =
|
||||
[
|
||||
"\u041c\u0435\u0441\u0442:",
|
||||
"\u041b\u0438\u043c\u0438\u0442:",
|
||||
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
|
||||
];
|
||||
private static readonly string[] RecurringCountPrefixes =
|
||||
[
|
||||
"\u0418\u0433\u0440:",
|
||||
"\u0421\u0435\u0441\u0441\u0438\u0439:",
|
||||
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
|
||||
];
|
||||
private static readonly string[] RecurringIntervalPrefixes =
|
||||
[
|
||||
"\u0428\u0430\u0433:",
|
||||
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
|
||||
];
|
||||
|
||||
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
|
||||
{
|
||||
string? title = null;
|
||||
string? link = null;
|
||||
string? imageUrl = null;
|
||||
int? maxPlayers = null;
|
||||
int? recurringCount = null;
|
||||
var recurringIntervalDays = 7;
|
||||
var scheduledTimes = new List<DateTimeOffset>();
|
||||
var pastTimeInputs = new List<string>();
|
||||
var invalidTimeInputs = new List<string>();
|
||||
var invalidSeatLimitInputs = new List<string>();
|
||||
var invalidRecurringInputs = new List<string>();
|
||||
|
||||
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
@@ -54,6 +80,14 @@ internal static class NewSessionCommandParser
|
||||
continue;
|
||||
}
|
||||
|
||||
var imagePrefix = ImagePrefixes.FirstOrDefault(prefix =>
|
||||
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (imagePrefix is not null)
|
||||
{
|
||||
imageUrl = line[imagePrefix.Length..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
|
||||
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (seatLimitPrefix is not null)
|
||||
@@ -71,6 +105,42 @@ internal static class NewSessionCommandParser
|
||||
continue;
|
||||
}
|
||||
|
||||
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
|
||||
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (recurringCountPrefix is not null)
|
||||
{
|
||||
var recurringInput = line[recurringCountPrefix.Length..].Trim();
|
||||
if (int.TryParse(recurringInput, out var parsedCount) &&
|
||||
parsedCount is >= 1 and <= MaxRecurringSessionCount)
|
||||
{
|
||||
recurringCount = parsedCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
invalidRecurringInputs.Add(recurringInput);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
|
||||
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
if (recurringIntervalPrefix is not null)
|
||||
{
|
||||
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
|
||||
if (int.TryParse(recurringInput, out var parsedInterval) &&
|
||||
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
|
||||
{
|
||||
recurringIntervalDays = parsedInterval;
|
||||
}
|
||||
else
|
||||
{
|
||||
invalidRecurringInputs.Add(recurringInput);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
@@ -92,13 +162,23 @@ internal static class NewSessionCommandParser
|
||||
scheduledTimes.Add(scheduledAt);
|
||||
}
|
||||
|
||||
if (recurringCount.HasValue && scheduledTimes.Count == 1)
|
||||
{
|
||||
var firstScheduledTime = scheduledTimes[0];
|
||||
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
|
||||
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return new NewSessionParseResult(
|
||||
title,
|
||||
link,
|
||||
imageUrl,
|
||||
maxPlayers,
|
||||
scheduledTimes,
|
||||
pastTimeInputs,
|
||||
invalidTimeInputs,
|
||||
invalidSeatLimitInputs);
|
||||
invalidSeatLimitInputs,
|
||||
invalidRecurringInputs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
|
||||
@@ -13,12 +14,12 @@ public sealed record PromoteWaitlistedPlayerCommand(
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, long GmId, int? MaxPlayers);
|
||||
internal sealed record PromoteWaitlistSessionDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, int? MaxPlayers);
|
||||
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||||
|
||||
public sealed class PromoteWaitlistedPlayerHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<PromoteWaitlistedPlayerHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
|
||||
@@ -33,27 +34,34 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
s.max_players AS MaxPlayers,
|
||||
g.gm_telegram_id AS GmId
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId
|
||||
FOR UPDATE
|
||||
""",
|
||||
new { command.SessionId },
|
||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
|
||||
transaction);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может поднимать игроков из листа ожидания.", showAlert: true, cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может поднимать игроков из листа ожидания.", ct, showAlert: true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,14 +90,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
if (waitlistedParticipants == 0)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Лист ожидания пуст.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", ct, showAlert: true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,7 +138,8 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
SELECT id AS SessionId,
|
||||
scheduled_at AS ScheduledAt,
|
||||
status AS Status,
|
||||
max_players AS MaxPlayers
|
||||
max_players AS MaxPlayers,
|
||||
join_link AS JoinLink
|
||||
FROM sessions
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at
|
||||
@@ -142,7 +151,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
@@ -156,17 +165,16 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
await transaction.CommitAsync(ct);
|
||||
transactionCommitted = true;
|
||||
|
||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions, batchParticipants);
|
||||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||||
var messageId = session.BatchMessageId ?? command.MessageId;
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
TelegramPlatformIds.Group(command.ChatId),
|
||||
view,
|
||||
TelegramPlatformIds.Message(command.ChatId, threadId: null, messageId)),
|
||||
ct);
|
||||
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: ct);
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -179,7 +187,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
var errorText = transactionCommitted
|
||||
? "Игрок повышен, но не удалось обновить сообщение расписания."
|
||||
: "Ошибка при обновлении листа ожидания.";
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, errorText, cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, errorText, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,25 @@
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||
|
||||
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||
|
||||
public sealed class ExportCalendarHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient botClient)
|
||||
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
|
||||
{
|
||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||
public Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand(
|
||||
new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
message.Chat.Id.ToString(),
|
||||
message.Chat.Title ?? "Private Chat",
|
||||
message.MessageThreadId?.ToString()),
|
||||
new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
message.From?.Id.ToString() ?? string.Empty,
|
||||
message.From?.FirstName ?? string.Empty,
|
||||
message.From?.Username));
|
||||
|
||||
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE g.telegram_chat_id = @ChatId
|
||||
AND s.status = @Planned
|
||||
AND s.scheduled_at > NOW()
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "📭 У этой группы нет запланированных сессий для экспорта.",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("BEGIN:VCALENDAR");
|
||||
sb.AppendLine("VERSION:2.0");
|
||||
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
|
||||
|
||||
foreach (var s in sessionsList)
|
||||
{
|
||||
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
|
||||
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
|
||||
|
||||
sb.AppendLine("BEGIN:VEVENT");
|
||||
sb.AppendLine($"UID:{s.Id}@gmrelay");
|
||||
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
|
||||
sb.AppendLine($"DTSTART:{dtStart}");
|
||||
sb.AppendLine($"DTEND:{dtEnd}");
|
||||
sb.AppendLine($"SUMMARY:{s.Title}");
|
||||
// Escape special chars according to iCal standards (RFC 5545) -- simple escaping for summary
|
||||
// In a fuller implementation we'd escape \r\n, commas, etc. But titles are mostly plain text.
|
||||
sb.AppendLine("END:VEVENT");
|
||||
}
|
||||
|
||||
sb.AppendLine("END:VCALENDAR");
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
using var stream = new MemoryStream(bytes);
|
||||
|
||||
var inputFile = InputFile.FromStream(stream, "schedule.ics");
|
||||
|
||||
await botClient.SendDocument(
|
||||
chatId: message.Chat.Id,
|
||||
document: inputFile,
|
||||
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
messageThreadId: message.MessageThreadId,
|
||||
cancellationToken: cancellationToken);
|
||||
return sharedHandler.HandleAsync(command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
|
||||
@@ -12,119 +10,88 @@ public sealed record DeleteSessionCommand(
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId);
|
||||
|
||||
public sealed class DeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
|
||||
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<DeleteSessionHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var platformUser = new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
command.TelegramUserId.ToString(),
|
||||
string.Empty,
|
||||
null);
|
||||
|
||||
// 1. Fetch session and verify GM
|
||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||
@"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId",
|
||||
new { command.SessionId }, transaction);
|
||||
var platformGroup = new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
command.ChatId.ToString(),
|
||||
string.Empty);
|
||||
|
||||
if (session == null)
|
||||
var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId);
|
||||
|
||||
var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand(
|
||||
command.SessionId,
|
||||
platformUser,
|
||||
platformGroup,
|
||||
scheduleMessage);
|
||||
|
||||
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Delete session
|
||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||
|
||||
// 3. Check if any sessions are left in the batch
|
||||
var remainingInBatch = await connection.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sessions WHERE batch_id = @BatchId",
|
||||
new { BatchId = session.BatchId }, transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// 4. If no sessions left and we have a forum topic, delete the topic
|
||||
if (remainingInBatch == 0 && session.ThreadId.HasValue)
|
||||
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
|
||||
if (result.ThreadId.HasValue &&
|
||||
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
|
||||
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
|
||||
await messenger.DeleteThreadAsync(
|
||||
new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
ct);
|
||||
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value);
|
||||
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct);
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
|
||||
ct);
|
||||
|
||||
// 5. Update the /listsessions message (we delete the message or edit it to remove the button)
|
||||
// A simple way is to re-render the list:
|
||||
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
||||
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||
g.gm_telegram_id as GmId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
ChatId = command.ChatId,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
// 5. Update the /listsessions message
|
||||
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
|
||||
var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
if (listResult.Sessions.Count == 0)
|
||||
{
|
||||
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch {}
|
||||
try
|
||||
{
|
||||
await messenger.UpdateGroupMessageAsync(
|
||||
scheduleMessage,
|
||||
"📭 В этой группе нет предстоящих игр.",
|
||||
[],
|
||||
ct);
|
||||
}
|
||||
catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||
foreach (var s in sessionsList)
|
||||
{
|
||||
var seats = s.MaxPlayers.HasValue
|
||||
? $"{s.PlayerCount}/{s.MaxPlayers.Value}"
|
||||
: s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty;
|
||||
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
|
||||
}
|
||||
|
||||
var isGm = command.TelegramUserId == sessionsList.First().GmId;
|
||||
var keyboard = isGm
|
||||
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||
: null;
|
||||
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
|
||||
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
|
||||
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
command.ChatId,
|
||||
command.MessageId,
|
||||
text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,72 +1,37 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
|
||||
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, long GmId);
|
||||
|
||||
public sealed class ListSessionsHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient botClient)
|
||||
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
|
||||
IPlatformMessenger messenger)
|
||||
{
|
||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(
|
||||
new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
message.Chat.Id.ToString(),
|
||||
message.Chat.Title ?? "Private Chat",
|
||||
message.MessageThreadId?.ToString()),
|
||||
new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
message.From?.Id.ToString() ?? string.Empty,
|
||||
message.From?.FirstName ?? string.Empty,
|
||||
message.From?.Username));
|
||||
|
||||
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||
g.gm_telegram_id as GmId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, g.gm_telegram_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
ChatId = message.Chat.Id,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
var result = await sharedHandler.HandleAsync(command, cancellationToken);
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
if (result.Sessions.Count == 0)
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "📭 В этой группе нет предстоящих игр.",
|
||||
cancellationToken: cancellationToken);
|
||||
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||
foreach (var s in sessionsList)
|
||||
{
|
||||
var seats = s.MaxPlayers.HasValue
|
||||
? $"{s.PlayerCount}/{s.MaxPlayers.Value}"
|
||||
: s.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var waitlist = s.WaitlistCount > 0 ? $", ожидание: {s.WaitlistCount}" : string.Empty;
|
||||
text += $"🔹 <b>{s.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(s.Title)} (Места: {seats}{waitlist})\n";
|
||||
}
|
||||
var text = SessionListMessageRenderer.RenderText(result.Sessions);
|
||||
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
|
||||
|
||||
var isGm = message.From?.Id == sessionsList.First().GmId;
|
||||
var keyboard = isGm
|
||||
? new Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup(
|
||||
sessionsList.Select(s => new[] { Telegram.Bot.Types.ReplyMarkups.InlineKeyboardButton.WithCallbackData($"🗑 Удалить {s.ScheduledAt.FormatMoscowShort()}", $"delete_session:{s.Id}") }))
|
||||
: null;
|
||||
|
||||
await botClient.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken);
|
||||
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
|
||||
internal static class SessionListMessageRenderer
|
||||
{
|
||||
public static string RenderText(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
|
||||
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
|
||||
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
if (sessions.Count == 0 || !sessions.First().CanManage)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var actions = new List<PlatformMessageAction>();
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"cancel_session:{session.Id}",
|
||||
$"❌ {dateTitle}",
|
||||
$"cancel_session:{session.Id}"));
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"reschedule_session:{session.Id}",
|
||||
$"⏰ {dateTitle}",
|
||||
$"reschedule_session:{session.Id}"));
|
||||
|
||||
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
|
||||
{
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"promote_waitlist:{session.Id}",
|
||||
$"⬆️ Из ожидания {dateTitle}",
|
||||
$"promote_waitlist:{session.Id}"));
|
||||
}
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"delete_session:{session.Id}",
|
||||
$"🗑 Удалить {dateTitle}",
|
||||
$"delete_session:{session.Id}"));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
+191
-184
@@ -1,262 +1,269 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record AwaitingProposalDto(
|
||||
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
|
||||
Guid BatchId, int? BatchMessageId, long TelegramChatId);
|
||||
|
||||
internal sealed record VoteParticipantDto(Guid PlayerId, string DisplayName, string? TelegramUsername);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Handles text input from the GM who has an AwaitingTime proposal.
|
||||
/// Parses the new time, creates a voting message, and tags all participants.
|
||||
/// If no participants are registered, reschedules immediately.
|
||||
/// Telegram adapter for reschedule time input.
|
||||
/// Delegates core logic to the shared handler, then performs Telegram-specific
|
||||
/// message sending, DM notifications, vote_message_id storage, and cleanup.
|
||||
/// </summary>
|
||||
public sealed class HandleRescheduleTimeInputHandler(
|
||||
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
IPlatformMessenger messenger,
|
||||
DirectSessionNotificationSender directSender,
|
||||
ILogger<HandleRescheduleTimeInputHandler> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to handle a text message as reschedule time input.
|
||||
/// Returns true if it was handled (i.e. user had an AwaitingTime proposal).
|
||||
/// </summary>
|
||||
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
|
||||
{
|
||||
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
||||
return false;
|
||||
|
||||
var gmTelegramId = message.From.Id;
|
||||
var chatId = message.Chat.Id;
|
||||
var text = message.Text.Trim();
|
||||
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
|
||||
new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
message.From.Id.ToString(),
|
||||
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
|
||||
message.From.Username),
|
||||
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
|
||||
message.Text.Trim());
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Check if this GM has an AwaitingTime proposal in this chat
|
||||
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
|
||||
"""
|
||||
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE rp.proposed_by = @GmId
|
||||
AND rp.status = 'AwaitingTime'
|
||||
AND g.telegram_chat_id = @ChatId
|
||||
ORDER BY rp.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { GmId = gmTelegramId, ChatId = chatId });
|
||||
|
||||
if (proposal is null)
|
||||
var result = await sharedHandler.HandleAsync(command, ct);
|
||||
if (!result.Handled)
|
||||
return false;
|
||||
|
||||
// 2. Parse the new time
|
||||
if (!MoscowTime.TryParseMoscow(text, out var newTime))
|
||||
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
await messenger.SendGroupMessageAsync(
|
||||
command.Group,
|
||||
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
|
||||
ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (newTime <= DateTimeOffset.UtcNow)
|
||||
if (result.IsRescheduledImmediately)
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.",
|
||||
cancellationToken: ct);
|
||||
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
|
||||
{
|
||||
await TryUpdateBatchMessage(
|
||||
command.Group,
|
||||
result.UpdatedView,
|
||||
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
|
||||
ct);
|
||||
}
|
||||
|
||||
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
|
||||
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Load participants (non-GM) signed up for this session
|
||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||
"""
|
||||
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
// Voting mode
|
||||
var voteText = BuildVotingMessage(
|
||||
result.Title!,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt!.Value,
|
||||
result.Options,
|
||||
result.Participants,
|
||||
[]);
|
||||
|
||||
// 4. If no participants — reschedule immediately
|
||||
if (participants.Count == 0)
|
||||
{
|
||||
await RescheduleImmediately(connection, proposal, newTime, chatId, ct);
|
||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Create voting message
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// Update proposal with proposed time and Voting status
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE reschedule_proposals
|
||||
SET proposed_at = @ProposedAt, status = 'Voting', vote_chat_id = @ChatId
|
||||
WHERE id = @Id
|
||||
""",
|
||||
new { ProposedAt = newTime, ChatId = chatId, Id = proposal.Id },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
// Build voting message text
|
||||
var voteText = BuildVotingMessage(proposal.Title, proposal.CurrentScheduledAt, newTime, participants, []);
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{proposal.Id}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{proposal.Id}")
|
||||
]
|
||||
]);
|
||||
var keyboard = BuildVotingKeyboard(result.Options);
|
||||
|
||||
var voteMsg = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
chatId: message.Chat.Id,
|
||||
messageThreadId: message.MessageThreadId,
|
||||
text: voteText,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
|
||||
// Store vote message ID
|
||||
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
var optionsText = string.Join(
|
||||
"\n",
|
||||
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
||||
var directText = $"""
|
||||
🔄 <b>Голосование за перенос сессии</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
🗳 Варианты:
|
||||
{optionsText}
|
||||
|
||||
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
|
||||
|
||||
Проголосуйте кнопкой в групповом сообщении.
|
||||
""";
|
||||
|
||||
await directSender.SendAsync(
|
||||
result.Participants.Select(p => new DirectNotificationRecipient(
|
||||
p.TelegramId,
|
||||
p.DisplayName)),
|
||||
directText,
|
||||
"reschedule-vote",
|
||||
result.ProposalId.Value,
|
||||
ct);
|
||||
}
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
||||
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
|
||||
new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
|
||||
|
||||
logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", proposal.SessionId, proposal.Id);
|
||||
|
||||
// Delete GM's time input message
|
||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
||||
logger.LogInformation(
|
||||
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
||||
result.ProposalId.Value,
|
||||
result.ProposalId.Value,
|
||||
result.Options.Count,
|
||||
result.VotingDeadlineAt.Value);
|
||||
|
||||
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RescheduleImmediately(
|
||||
NpgsqlConnection connection, AwaitingProposalDto proposal,
|
||||
DateTimeOffset newTime, long chatId, CancellationToken ct)
|
||||
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
|
||||
{
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
|
||||
"""
|
||||
UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
SELECT s.notification_mode
|
||||
FROM sessions s
|
||||
JOIN reschedule_proposals rp ON rp.session_id = s.id
|
||||
WHERE rp.id = @Id
|
||||
""",
|
||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||||
transaction);
|
||||
new { Id = proposalId });
|
||||
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
||||
new { NewTime = newTime, Id = proposal.Id },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
text: $"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
|
||||
// Re-render batch message with updated time
|
||||
await TryUpdateBatchMessage(proposal, ct);
|
||||
|
||||
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
|
||||
private async Task TryUpdateBatchMessage(
|
||||
PlatformGroup group,
|
||||
SessionBatchViewModel view,
|
||||
PlatformMessageRef scheduleMessage,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(group, view, scheduleMessage),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
|
||||
}
|
||||
}
|
||||
|
||||
internal static string BuildVotingMessage(
|
||||
string title, DateTime currentTime, DateTimeOffset newTime,
|
||||
string title,
|
||||
DateTime currentTime,
|
||||
DateTimeOffset deadline,
|
||||
IReadOnlyList<RescheduleOptionDto> options,
|
||||
IReadOnlyList<VoteParticipantDto> participants,
|
||||
IReadOnlyCollection<Guid> approvedPlayerIds)
|
||||
IReadOnlyList<RescheduleOptionVoteDto> votes)
|
||||
{
|
||||
var votesByOption = votes
|
||||
.GroupBy(v => v.OptionId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
|
||||
var pendingParticipants = participants
|
||||
.Where(p => !votedPlayerIds.Contains(p.PlayerId))
|
||||
.Select(FormatParticipantName)
|
||||
.ToList();
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
||||
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
|
||||
"",
|
||||
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
||||
$"📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)",
|
||||
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
|
||||
"",
|
||||
"Для переноса нужно согласие всех участников:"
|
||||
"Выберите один из вариантов:"
|
||||
};
|
||||
|
||||
foreach (var p in participants)
|
||||
foreach (var option in options.OrderBy(x => x.DisplayOrder))
|
||||
{
|
||||
var name = p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
||||
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳";
|
||||
lines.Add($" {icon} {name}");
|
||||
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
|
||||
lines.Add(
|
||||
$"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК) — {FormatVoteCount(optionVotes.Count)}");
|
||||
|
||||
if (optionVotes.Count > 0)
|
||||
{
|
||||
lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}");
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingParticipants.Count > 0)
|
||||
{
|
||||
lines.Add("");
|
||||
lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}");
|
||||
}
|
||||
|
||||
lines.Add("");
|
||||
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count} ✅");
|
||||
lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
|
||||
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
||||
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||||
""",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
if (proposal.BatchMessageId.HasValue)
|
||||
{
|
||||
var renderResult = SessionBatchRenderer.Render(
|
||||
proposal.Title, batchSessions, batchParticipants);
|
||||
|
||||
await bot.EditMessageText(
|
||||
chatId: proposal.TelegramChatId,
|
||||
messageId: proposal.BatchMessageId.Value,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
|
||||
}
|
||||
return new InlineKeyboardMarkup(
|
||||
options
|
||||
.OrderBy(option => option.DisplayOrder)
|
||||
.Select(option => new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
|
||||
$"reschedule_vote:{option.OptionId}")
|
||||
}));
|
||||
}
|
||||
|
||||
internal static string FormatParticipantName(VoteParticipantDto participant)
|
||||
{
|
||||
return participant.TelegramUsername is { Length: > 0 } username
|
||||
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
|
||||
: System.Net.WebUtility.HtmlEncode(participant.DisplayName);
|
||||
}
|
||||
|
||||
internal static string FormatParticipantName(RescheduleOptionVoteDto vote)
|
||||
{
|
||||
return vote.TelegramUsername is { Length: > 0 } username
|
||||
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
|
||||
: System.Net.WebUtility.HtmlEncode(vote.DisplayName);
|
||||
}
|
||||
|
||||
private static string FormatVoteCount(int count)
|
||||
{
|
||||
var modulo100 = count % 100;
|
||||
var modulo10 = count % 10;
|
||||
var word = modulo100 is >= 11 and <= 14
|
||||
? "голосов"
|
||||
: modulo10 switch
|
||||
{
|
||||
1 => "голос",
|
||||
>= 2 and <= 4 => "голоса",
|
||||
_ => "голосов"
|
||||
};
|
||||
|
||||
return $"{count} {word}";
|
||||
}
|
||||
|
||||
private static string FormatButtonTime(DateTimeOffset utc)
|
||||
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString(
|
||||
"dd.MM HH:mm",
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
|
||||
+48
-278
@@ -1,310 +1,80 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed record HandleRescheduleVoteCommand(
|
||||
Guid ProposalId,
|
||||
string Vote,
|
||||
Guid OptionId,
|
||||
long TelegramUserId,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int MessageId);
|
||||
|
||||
internal sealed record VoteProposalDto(
|
||||
Guid Id,
|
||||
Guid SessionId,
|
||||
DateTime ProposedAt,
|
||||
string Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
Guid BatchId,
|
||||
string SessionStatus,
|
||||
long TelegramChatId,
|
||||
int? ConfirmationMessageId,
|
||||
int? BatchMessageId);
|
||||
|
||||
public sealed class HandleRescheduleVoteHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||
ITelegramBotClient bot,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<HandleRescheduleVoteHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var platformUser = new PlatformUser(
|
||||
PlatformKind.Telegram,
|
||||
command.TelegramUserId.ToString(),
|
||||
string.Empty,
|
||||
null);
|
||||
|
||||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
||||
"""
|
||||
SELECT rp.id AS Id,
|
||||
rp.session_id AS SessionId,
|
||||
rp.proposed_at AS ProposedAt,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS CurrentScheduledAt,
|
||||
s.batch_id AS BatchId,
|
||||
s.status AS SessionStatus,
|
||||
s.confirmation_message_id AS ConfirmationMessageId,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE rp.id = @ProposalId AND rp.status = 'Voting'
|
||||
""",
|
||||
new { command.ProposalId },
|
||||
transaction);
|
||||
var platformGroup = new PlatformGroup(
|
||||
PlatformKind.Telegram,
|
||||
command.ChatId.ToString(),
|
||||
string.Empty);
|
||||
|
||||
if (proposal is null)
|
||||
var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand(
|
||||
command.OptionId,
|
||||
platformUser,
|
||||
platformGroup,
|
||||
command.CallbackQueryId,
|
||||
TelegramPlatformIds.Message(command.ChatId, null, command.MessageId));
|
||||
|
||||
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(
|
||||
command.CallbackQueryId,
|
||||
"Голосование уже завершено или не найдено.",
|
||||
cancellationToken: ct);
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
||||
"""
|
||||
SELECT p.id
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.telegram_id = @TelegramUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||
result.Title!,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt,
|
||||
result.Options,
|
||||
result.Participants,
|
||||
result.Votes);
|
||||
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
|
||||
|
||||
if (playerId is null)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(
|
||||
command.CallbackQueryId,
|
||||
"Вы не являетесь участником этой сессии.",
|
||||
cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_votes (proposal_id, player_id, vote)
|
||||
VALUES (@ProposalId, @PlayerId, @Vote)
|
||||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||||
SET vote = EXCLUDED.vote,
|
||||
voted_at = now()
|
||||
""",
|
||||
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
|
||||
transaction);
|
||||
|
||||
var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
|
||||
? new List<VoteParticipantDto>()
|
||||
: (await connection.QueryAsync<VoteParticipantDto>(
|
||||
"""
|
||||
SELECT p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction)).ToList();
|
||||
|
||||
var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
|
||||
? new HashSet<Guid>()
|
||||
: (await connection.QueryAsync<Guid>(
|
||||
"""
|
||||
SELECT player_id
|
||||
FROM reschedule_votes
|
||||
WHERE proposal_id = @ProposalId AND vote = 'yes'
|
||||
""",
|
||||
new { command.ProposalId },
|
||||
transaction)).ToHashSet();
|
||||
|
||||
var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count);
|
||||
|
||||
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
|
||||
new { Id = command.ProposalId },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
|
||||
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
||||
new { command.TelegramUserId });
|
||||
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: $"❌ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» отклонён!</b>\n\n{voterName ?? "Участник"} проголосовал(а) против. Время сессии остаётся прежним:\n📅 <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update vote message after rejection");
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
|
||||
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.ShouldRescheduleSession)
|
||||
{
|
||||
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET scheduled_at = @NewTime,
|
||||
status = @Status,
|
||||
confirmation_message_id = NULL,
|
||||
link_message_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET status = 'Approved' WHERE id = @Id",
|
||||
new { Id = command.ProposalId },
|
||||
transaction);
|
||||
|
||||
if (decision.ShouldResetParticipantRsvps)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE session_participants
|
||||
SET rsvp_status = 'Pending',
|
||||
responded_at = NULL
|
||||
WHERE session_id = @SessionId AND is_gm = false
|
||||
AND registration_status = @Active
|
||||
""",
|
||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: $"✅ <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(proposal.Title)}» одобрен!</b>\n\nВсе участники согласились.\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)\n\n<i>Уведомления будут приходить согласно новому расписанию.</i>",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update vote message after approval");
|
||||
}
|
||||
|
||||
await TryUpdateBatchMessage(proposal, ct);
|
||||
|
||||
logger.LogInformation(
|
||||
"Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
|
||||
proposal.SessionId,
|
||||
newTime,
|
||||
command.ProposalId);
|
||||
}
|
||||
else
|
||||
{
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||
proposal.Title,
|
||||
proposal.CurrentScheduledAt,
|
||||
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
|
||||
participants,
|
||||
approvedPlayerIds);
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Согласен", $"reschedule_vote:yes:{command.ProposalId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Против", $"reschedule_vote:no:{command.ProposalId}")
|
||||
]
|
||||
]);
|
||||
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: voteText,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update vote message with progress");
|
||||
}
|
||||
}
|
||||
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
|
||||
}
|
||||
|
||||
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||||
""",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
if (proposal.BatchMessageId.HasValue)
|
||||
{
|
||||
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
|
||||
|
||||
await bot.EditMessageText(
|
||||
chatId: proposal.TelegramChatId,
|
||||
messageId: proposal.BatchMessageId.Value,
|
||||
text: renderResult.Text,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: proposal.TelegramChatId,
|
||||
text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
await bot.EditMessageText(
|
||||
chatId: command.ChatId,
|
||||
messageId: command.MessageId,
|
||||
text: voteText,
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update batch message for proposal {ProposalId}", proposal.Id);
|
||||
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
|
||||
}
|
||||
|
||||
await messenger.AnswerInteractionAsync(
|
||||
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
@@ -12,48 +13,55 @@ public sealed record InitiateRescheduleCommand(
|
||||
long TelegramUserId,
|
||||
string CallbackQueryId,
|
||||
long ChatId,
|
||||
int? MessageThreadId,
|
||||
int MessageId);
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
|
||||
internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
|
||||
|
||||
// ── Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Handles the "⏰ Перенести" button press from the batch message.
|
||||
/// Creates a reschedule proposal in AwaitingTime status and prompts
|
||||
/// the GM to enter the new time via a regular text message.
|
||||
/// the GM to enter 2-3 new time options and a voting deadline.
|
||||
/// </summary>
|
||||
public sealed class InitiateRescheduleHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
ITelegramBotClient bot,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<InitiateRescheduleHandler> logger)
|
||||
{
|
||||
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
// 1. Verify GM ownership
|
||||
// 1. Verify group management access.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title, g.gm_telegram_id AS GmId
|
||||
SELECT s.title AS Title,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
||||
""",
|
||||
new { command.SessionId, Cancelled = SessionStatus.Cancelled });
|
||||
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.GmId != command.TelegramUserId)
|
||||
if (!session.CanManage)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может переносить сессию.", ct, showAlert: true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,29 +77,43 @@ public sealed class InitiateRescheduleHandler(
|
||||
|
||||
if (hasActive)
|
||||
{
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Уже есть активный запрос на перенос этой сессии.", ct, showAlert: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Create proposal in AwaitingTime status
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_proposals (session_id, proposed_by, status)
|
||||
VALUES (@SessionId, @GmId, 'AwaitingTime')
|
||||
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
|
||||
VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
|
||||
""",
|
||||
new { command.SessionId, GmId = command.TelegramUserId });
|
||||
new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
|
||||
|
||||
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
||||
|
||||
// 4. Prompt GM in chat
|
||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
||||
"Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct);
|
||||
await AnswerAsync(command.CallbackQueryId, "Введите 2-3 варианта времени и дедлайн голосования.", ct);
|
||||
|
||||
await bot.SendMessage(
|
||||
chatId: command.ChatId,
|
||||
text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\n<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\nНапример: <code>25.04.2026 19:30</code>",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
var prompt = string.Join(
|
||||
"\n",
|
||||
new[]
|
||||
{
|
||||
$"⏰ Укажите 2-3 варианта времени для сессии «{session.Title}» и дедлайн голосования.",
|
||||
"",
|
||||
"Формат:",
|
||||
"<code>25.04.2026 19:30",
|
||||
"26.04.2026 18:00",
|
||||
"Дедлайн: 25.04.2026 12:00</code>",
|
||||
"",
|
||||
"Дедлайн должен быть в будущем и раньше первого предложенного времени."
|
||||
});
|
||||
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
|
||||
prompt,
|
||||
ct);
|
||||
}
|
||||
|
||||
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
|
||||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
internal enum RescheduleVoteOutcome
|
||||
{
|
||||
Pending,
|
||||
Rejected,
|
||||
Approved
|
||||
}
|
||||
|
||||
internal sealed record RescheduleVoteDecision(
|
||||
RescheduleVoteOutcome Outcome,
|
||||
string CallbackText,
|
||||
bool ShouldRescheduleSession,
|
||||
bool ShouldResetParticipantRsvps);
|
||||
|
||||
internal static class RescheduleVoteRules
|
||||
{
|
||||
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
|
||||
{
|
||||
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new RescheduleVoteDecision(
|
||||
Outcome: RescheduleVoteOutcome.Rejected,
|
||||
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.",
|
||||
ShouldRescheduleSession: false,
|
||||
ShouldResetParticipantRsvps: false);
|
||||
}
|
||||
|
||||
var everyoneApproved = approvedParticipants == totalParticipants;
|
||||
|
||||
return new RescheduleVoteDecision(
|
||||
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
|
||||
CallbackText: everyoneApproved
|
||||
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
|
||||
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
|
||||
ShouldRescheduleSession: everyoneApproved,
|
||||
ShouldResetParticipantRsvps: everyoneApproved);
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
using Dapper;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
|
||||
internal sealed record TelegramProposalFieldsDto(
|
||||
int? VoteMessageId,
|
||||
int? BatchMessageId,
|
||||
long TelegramChatId,
|
||||
int? ThreadId);
|
||||
|
||||
public sealed class RescheduleVotingDeadlineService(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
PlatformDirectNotificationSender directSender,
|
||||
RescheduleVotingFinalizer finalizer,
|
||||
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessDueProposals(stoppingToken);
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await ProcessDueProposals(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessDueProposals(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var proposalIds = await finalizer.GetDueProposalIdsAsync("Telegram", ct);
|
||||
|
||||
foreach (var proposalId in proposalIds)
|
||||
{
|
||||
await FinalizeProposal(proposalId, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to process due reschedule voting proposals");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FinalizeProposal(Guid proposalId, CancellationToken ct)
|
||||
{
|
||||
var result = await finalizer.FinalizeAsync(proposalId, ct);
|
||||
if (result is null)
|
||||
return;
|
||||
|
||||
if (result.SourcePlatform != "Telegram")
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Skipping Telegram message handling for proposal {ProposalId} with source platform {SourcePlatform}",
|
||||
proposalId,
|
||||
result.SourcePlatform);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var telegramFields = await connection.QuerySingleOrDefaultAsync<TelegramProposalFieldsDto>(
|
||||
"""
|
||||
SELECT rp.vote_message_id AS VoteMessageId,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.thread_id AS ThreadId
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE rp.id = @ProposalId
|
||||
""",
|
||||
new { ProposalId = proposalId });
|
||||
|
||||
if (telegramFields is null)
|
||||
{
|
||||
logger.LogWarning("Could not find Telegram fields for proposal {ProposalId}", proposalId);
|
||||
return;
|
||||
}
|
||||
|
||||
var directRecipients = result.Participants
|
||||
.Select(p => TelegramPlatformIds.User(p.TelegramId, p.DisplayName))
|
||||
.ToList();
|
||||
|
||||
await TryUpdateVoteMessage(result, telegramFields, ct);
|
||||
|
||||
if (result.SelectedOption is not null)
|
||||
{
|
||||
await TryUpdateBatchMessage(result, telegramFields, ct);
|
||||
}
|
||||
|
||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(result.NotificationMode);
|
||||
if (mode.ShouldSendDirectMessages())
|
||||
{
|
||||
await SendDirectResult(result, directRecipients, ct);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Updated Telegram messages for finalized reschedule proposal {ProposalId} for session {SessionId}",
|
||||
result.ProposalId,
|
||||
result.SessionId);
|
||||
}
|
||||
|
||||
private async Task TryUpdateVoteMessage(
|
||||
RescheduleVotingFinalizerResult result,
|
||||
TelegramProposalFieldsDto telegramFields,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (telegramFields.VoteMessageId is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await messenger.UpdateRescheduleVoteAsync(
|
||||
new PlatformRescheduleVoteUpdate(
|
||||
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
|
||||
TelegramPlatformIds.Message(
|
||||
telegramFields.TelegramChatId,
|
||||
telegramFields.ThreadId,
|
||||
telegramFields.VoteMessageId.Value),
|
||||
result.ProposalId,
|
||||
result.SessionId,
|
||||
result.Title,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt,
|
||||
result.Decision,
|
||||
result.SelectedOption,
|
||||
result.Options,
|
||||
result.Votes,
|
||||
result.Participants),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update finalized reschedule vote message for proposal {ProposalId}", result.ProposalId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryUpdateBatchMessage(
|
||||
RescheduleVotingFinalizerResult result,
|
||||
TelegramProposalFieldsDto telegramFields,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { result.BatchId })).ToList();
|
||||
|
||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||||
""",
|
||||
new { result.BatchId })).ToList();
|
||||
|
||||
if (telegramFields.BatchMessageId.HasValue)
|
||||
{
|
||||
var view = SessionBatchViewBuilder.Build(result.Title, batchSessions, batchParticipants);
|
||||
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
|
||||
view,
|
||||
TelegramPlatformIds.Message(telegramFields.TelegramChatId, telegramFields.ThreadId, telegramFields.BatchMessageId.Value)),
|
||||
ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
TelegramPlatformIds.Group(telegramFields.TelegramChatId, telegramFields.ThreadId),
|
||||
$"Расписание обновлено после голосования за перенос сессии \"{System.Net.WebUtility.HtmlEncode(result.Title)}\".",
|
||||
ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update batch message for finalized proposal {ProposalId}", result.ProposalId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendDirectResult(
|
||||
RescheduleVotingFinalizerResult result,
|
||||
IReadOnlyList<PlatformUser> recipients,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await directSender.SendAsync(
|
||||
result.SelectedOption is not null
|
||||
? PlatformDirectSessionNotificationKind.RescheduleApproved
|
||||
: PlatformDirectSessionNotificationKind.RescheduleRejected,
|
||||
recipients,
|
||||
result.SessionId,
|
||||
result.Title,
|
||||
result.SelectedOption?.ProposedAt.UtcDateTime ?? result.CurrentScheduledAt,
|
||||
joinLink: null,
|
||||
actorDisplayName: null,
|
||||
reason: result.SelectedOption is null ? result.Decision.Reason : null,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Health;
|
||||
|
||||
public sealed class BotHealthCheckHostedService : IHostedService
|
||||
{
|
||||
private readonly ILogger<BotHealthCheckHostedService> _logger;
|
||||
private readonly string _prefix;
|
||||
private HttpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _listenerTask;
|
||||
|
||||
public BotHealthCheckHostedService(
|
||||
ILogger<BotHealthCheckHostedService> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8081/")!;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add(_prefix);
|
||||
_listener.Start();
|
||||
|
||||
_logger.LogInformation("Health check server started on {Prefix}", _prefix);
|
||||
|
||||
_listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_listener?.Stop();
|
||||
|
||||
if (_listenerTask != null)
|
||||
{
|
||||
await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
|
||||
}
|
||||
|
||||
_listener?.Close();
|
||||
_logger.LogInformation("Health check server stopped");
|
||||
}
|
||||
|
||||
private async Task ListenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _listener.GetContextAsync();
|
||||
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
|
||||
}
|
||||
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in health check listener");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
var response = context.Response;
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
|
||||
if (request.Url?.AbsolutePath == "/health")
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.OK;
|
||||
response.ContentType = "application/json";
|
||||
var body = "{\"status\":\"healthy\"}"u8.ToArray();
|
||||
await response.OutputStream.WriteAsync(body);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.NotFound;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling health check request");
|
||||
}
|
||||
finally
|
||||
{
|
||||
response.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||
|
||||
public sealed class SystemClock : ISystemClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class FakeSystemClock : ISystemClock
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
|
||||
/// Two triggers:
|
||||
/// T-24h: send confirmation request with inline keyboard
|
||||
/// T-5min: send join link to all confirmed players
|
||||
///
|
||||
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
|
||||
/// </summary>
|
||||
public sealed class SessionSchedulerService(
|
||||
NpgsqlDataSource dataSource,
|
||||
SendConfirmationHandler confirmationHandler,
|
||||
SendJoinLinkHandler joinLinkHandler,
|
||||
ILogger<SessionSchedulerService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("Session scheduler started (interval: {Interval})", TickInterval);
|
||||
|
||||
using var timer = new PeriodicTimer(TickInterval);
|
||||
|
||||
// Run immediately on startup, then on each tick
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessConfirmationTriggers(stoppingToken);
|
||||
await ProcessJoinLinkTriggers(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Scheduler tick failed, will retry next tick");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||
|
||||
logger.LogInformation("Session scheduler stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-24h trigger: find sessions that need confirmation requests sent.
|
||||
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
|
||||
/// </summary>
|
||||
private async Task ProcessConfirmationTriggers(CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var sessionIds = await connection.QueryAsync<Guid>(
|
||||
"""
|
||||
SELECT id
|
||||
FROM sessions
|
||||
WHERE status = @Planned
|
||||
AND scheduled_at - @LeadTime <= now()
|
||||
""",
|
||||
new { Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime });
|
||||
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await confirmationHandler.HandleAsync(sessionId, ct);
|
||||
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T-5min trigger: find confirmed sessions that need join links sent.
|
||||
/// Condition: status='Confirmed' AND scheduled_at minus 5min is in the past AND link not yet sent.
|
||||
/// </summary>
|
||||
private async Task ProcessJoinLinkTriggers(CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var sessionIds = await connection.QueryAsync<Guid>(
|
||||
"""
|
||||
SELECT id
|
||||
FROM sessions
|
||||
WHERE status = @Confirmed
|
||||
AND scheduled_at - @LeadTime <= now()
|
||||
AND link_message_id IS NULL
|
||||
""",
|
||||
new { Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime });
|
||||
|
||||
foreach (var sessionId in sessionIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
await joinLinkHandler.HandleAsync(sessionId, ct);
|
||||
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Handles editing batch messages that may be either text or photo messages.
|
||||
/// When the batch was created with SendPhoto (image + caption), we need
|
||||
/// EditMessageCaption instead of EditMessageText.
|
||||
/// </summary>
|
||||
public static class BatchMessageEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Edits a batch message, automatically detecting whether it is a text or photo message.
|
||||
/// Tries EditMessageText first; on failure falls back to EditMessageCaption.
|
||||
/// </summary>
|
||||
public static async Task EditBatchMessageAsync(
|
||||
ITelegramBotClient bot,
|
||||
long chatId,
|
||||
int messageId,
|
||||
string text,
|
||||
InlineKeyboardMarkup? replyMarkup,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.EditMessageText(
|
||||
chatId: chatId,
|
||||
messageId: messageId,
|
||||
text: text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: replyMarkup,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (global::Telegram.Bot.Exceptions.ApiRequestException ex)
|
||||
when (ex.Message.Contains("there is no text in the message", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// The batch message is a photo — use EditMessageCaption instead.
|
||||
// Caption is limited to 1024 chars; if text exceeds that, truncate gracefully.
|
||||
var caption = text.Length <= 1024 ? text : text[..1021] + "...";
|
||||
await bot.EditMessageCaption(
|
||||
chatId: chatId,
|
||||
messageId: messageId,
|
||||
caption: caption,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: replyMarkup,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
public sealed class TelegramMiniAppMenuButtonService(
|
||||
ITelegramBotClient bot,
|
||||
IConfiguration configuration,
|
||||
ILogger<TelegramMiniAppMenuButtonService> logger) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
|
||||
if (string.IsNullOrWhiteSpace(miniAppUrl))
|
||||
{
|
||||
logger.LogInformation("Telegram Mini App URL is not configured; menu button setup skipped.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(miniAppUrl, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttps && !uri.IsLoopback))
|
||||
{
|
||||
logger.LogWarning("Telegram Mini App URL {MiniAppUrl} is not a valid HTTPS URL.", miniAppUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await bot.SetChatMenuButton(
|
||||
menuButton: new MenuButtonWebApp
|
||||
{
|
||||
Text = "Dashboard",
|
||||
WebApp = new WebAppInfo(miniAppUrl)
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
logger.LogInformation("Telegram Mini App menu button configured for {MiniAppUrl}.", miniAppUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to configure Telegram Mini App menu button.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Globalization;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
internal static class TelegramPlatformIds
|
||||
{
|
||||
public static PlatformGroup Group(long chatId, int? threadId = null, string? displayName = null) =>
|
||||
new(
|
||||
PlatformKind.Telegram,
|
||||
chatId.ToString(CultureInfo.InvariantCulture),
|
||||
displayName ?? "Telegram chat",
|
||||
ExternalChannelId: chatId.ToString(CultureInfo.InvariantCulture),
|
||||
ExternalThreadId: threadId?.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
public static PlatformUser User(long telegramId, string displayName, string? username = null) =>
|
||||
new(
|
||||
PlatformKind.Telegram,
|
||||
telegramId.ToString(CultureInfo.InvariantCulture),
|
||||
displayName,
|
||||
username);
|
||||
|
||||
public static PlatformMessageRef Message(long chatId, int? threadId, int messageId) =>
|
||||
new(
|
||||
PlatformKind.Telegram,
|
||||
chatId.ToString(CultureInfo.InvariantCulture),
|
||||
threadId?.ToString(CultureInfo.InvariantCulture),
|
||||
messageId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
using System.Globalization;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
public sealed class TelegramPlatformMessenger(
|
||||
ITelegramBotClient bot,
|
||||
ILogger<TelegramPlatformMessenger> logger) : IPlatformMessenger
|
||||
{
|
||||
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(message.Group.Platform);
|
||||
|
||||
var chatId = ParseLong(message.Group.ExternalGroupId);
|
||||
var threadId = ParseNullableInt(message.Group.ExternalThreadId);
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
|
||||
Message sentMessage;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message.ImageReference) && renderResult.Text.Length <= 1024)
|
||||
{
|
||||
try
|
||||
{
|
||||
sentMessage = await bot.SendPhoto(
|
||||
chatId: chatId,
|
||||
messageThreadId: threadId,
|
||||
photo: InputFile.FromString(message.ImageReference),
|
||||
caption: renderResult.Text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: renderResult.Markup,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send Telegram schedule image for group {ExternalGroupId}", message.Group.ExternalGroupId);
|
||||
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message.ImageReference))
|
||||
{
|
||||
await TrySendScheduleImageOnly(chatId, threadId, message.View.Title, message.ImageReference, ct);
|
||||
}
|
||||
|
||||
sentMessage = await SendScheduleTextMessage(chatId, threadId, renderResult.Text, renderResult.Markup, ct);
|
||||
}
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Telegram,
|
||||
message.Group.ExternalGroupId,
|
||||
message.Group.ExternalThreadId,
|
||||
sentMessage.MessageId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(message.Group.Platform);
|
||||
var existingMessage = message.ExistingMessage;
|
||||
if (existingMessage is null)
|
||||
{
|
||||
throw new ArgumentException("Existing schedule message reference is required.", nameof(message));
|
||||
}
|
||||
|
||||
EnsureTelegram(existingMessage.Platform);
|
||||
if (!string.Equals(message.Group.ExternalGroupId, existingMessage.ExternalGroupId, StringComparison.Ordinal) ||
|
||||
!string.Equals(message.Group.ExternalThreadId, existingMessage.ExternalThreadId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Existing schedule message reference must match the schedule group.", nameof(message));
|
||||
}
|
||||
|
||||
var renderResult = TelegramSessionBatchRenderer.Render(message.View);
|
||||
await BatchMessageEditor.EditBatchMessageAsync(
|
||||
bot,
|
||||
chatId: ParseLong(existingMessage.ExternalGroupId),
|
||||
messageId: ParseInt(existingMessage.ExternalMessageId),
|
||||
text: renderResult.Text,
|
||||
replyMarkup: renderResult.Markup,
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(group.Platform);
|
||||
return bot.SendMessage(
|
||||
chatId: ParseLong(group.ExternalGroupId),
|
||||
messageThreadId: ParseNullableInt(group.ExternalThreadId),
|
||||
text: htmlText,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(group.Platform);
|
||||
await bot.SendMessage(
|
||||
chatId: ParseLong(group.ExternalGroupId),
|
||||
messageThreadId: ParseNullableInt(group.ExternalThreadId),
|
||||
text: htmlText,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: BuildActionsMarkup(actions),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(messageRef.Platform);
|
||||
await bot.EditMessageText(
|
||||
chatId: ParseLong(messageRef.ExternalGroupId),
|
||||
messageId: ParseInt(messageRef.ExternalMessageId),
|
||||
text: htmlText,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: BuildActionsMarkup(actions),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(group.Platform);
|
||||
var topic = await bot.CreateForumTopic(
|
||||
chatId: ParseLong(group.ExternalGroupId),
|
||||
name: title,
|
||||
cancellationToken: ct);
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Telegram,
|
||||
group.ExternalGroupId,
|
||||
topic.MessageThreadId.ToString(CultureInfo.InvariantCulture),
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(group.Platform);
|
||||
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return bot.DeleteForumTopic(
|
||||
ParseLong(group.ExternalGroupId),
|
||||
ParseInt(group.ExternalThreadId),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(messageRef.Platform);
|
||||
return bot.DeleteMessage(
|
||||
ParseLong(messageRef.ExternalGroupId),
|
||||
ParseInt(messageRef.ExternalMessageId),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(message.Recipient.Platform);
|
||||
return bot.SendMessage(
|
||||
chatId: ParseLong(message.Recipient.ExternalUserId),
|
||||
text: message.HtmlText,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) =>
|
||||
bot.AnswerCallbackQuery(
|
||||
callbackQueryId: reply.InteractionId,
|
||||
text: reply.Text,
|
||||
showAlert: reply.ShowAlert,
|
||||
cancellationToken: ct);
|
||||
|
||||
public async Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(file.Group.Platform);
|
||||
|
||||
using var stream = new MemoryStream(file.Content);
|
||||
await bot.SendDocument(
|
||||
chatId: ParseLong(file.Group.ExternalGroupId),
|
||||
messageThreadId: ParseNullableInt(file.Group.ExternalThreadId),
|
||||
document: InputFile.FromStream(stream, file.FileName),
|
||||
caption: file.CaptionHtml,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: BuildActionsMarkup(file.Actions),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(PlatformConfirmationRequest request, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(request.Group.Platform);
|
||||
|
||||
var chatId = ParseLong(request.Group.ExternalGroupId);
|
||||
var threadId = ParseNullableInt(request.Group.ExternalThreadId);
|
||||
var message = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: threadId,
|
||||
text: BuildConfirmationText(request),
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: BuildRsvpKeyboard(request.SessionId),
|
||||
cancellationToken: ct);
|
||||
|
||||
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
|
||||
}
|
||||
|
||||
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
||||
{
|
||||
var request = update.Request;
|
||||
EnsureTelegram(request.Group.Platform);
|
||||
var existingMessage = request.ExistingMessage
|
||||
?? throw new ArgumentException("Existing confirmation message reference is required.", nameof(update));
|
||||
|
||||
EnsureTelegram(existingMessage.Platform);
|
||||
await bot.EditMessageText(
|
||||
chatId: ParseLong(existingMessage.ExternalGroupId),
|
||||
messageId: ParseInt(existingMessage.ExternalMessageId),
|
||||
text: BuildConfirmationText(request),
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: update.DisableActions ? null : BuildRsvpKeyboard(request.SessionId),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
|
||||
PlatformJoinLinkNotification notification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(notification.Group.Platform);
|
||||
|
||||
var chatId = ParseLong(notification.Group.ExternalGroupId);
|
||||
var threadId = ParseNullableInt(notification.Group.ExternalThreadId);
|
||||
var message = await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: threadId,
|
||||
text: BuildJoinLinkText(notification),
|
||||
cancellationToken: ct);
|
||||
|
||||
return TelegramPlatformIds.Message(chatId, threadId, message.MessageId);
|
||||
}
|
||||
|
||||
public Task SendDirectSessionNotificationAsync(
|
||||
PlatformDirectSessionNotification notification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(notification.Recipient.Platform);
|
||||
return bot.SendMessage(
|
||||
chatId: ParseLong(notification.Recipient.ExternalUserId),
|
||||
text: BuildDirectNotificationText(notification),
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
|
||||
{
|
||||
switch (notification.Kind)
|
||||
{
|
||||
case PlatformRsvpOutcomeKind.GroupAllConfirmed:
|
||||
if (notification.Group is null)
|
||||
{
|
||||
throw new ArgumentException("Group notification requires a group.", nameof(notification));
|
||||
}
|
||||
|
||||
EnsureTelegram(notification.Group.Platform);
|
||||
await bot.SendMessage(
|
||||
chatId: ParseLong(notification.Group.ExternalGroupId),
|
||||
messageThreadId: ParseNullableInt(notification.Group.ExternalThreadId),
|
||||
text: $"🎉 Игра «{notification.Title}» подтверждена! Все участники на месте.",
|
||||
cancellationToken: ct);
|
||||
break;
|
||||
|
||||
case PlatformRsvpOutcomeKind.GmAllConfirmed:
|
||||
case PlatformRsvpOutcomeKind.GmPlayerDeclined:
|
||||
foreach (var recipient in notification.Recipients)
|
||||
{
|
||||
EnsureTelegram(recipient.Platform);
|
||||
await bot.SendMessage(
|
||||
chatId: ParseLong(recipient.ExternalUserId),
|
||||
text: BuildRsvpOutcomeDirectText(notification),
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(notification), notification.Kind, "Unknown RSVP outcome kind.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
|
||||
{
|
||||
EnsureTelegram(update.Group.Platform);
|
||||
EnsureTelegram(update.ExistingMessage.Platform);
|
||||
|
||||
var resultText = update.SelectedOption is not null
|
||||
? $"✅ <b>Голосование завершено.</b>\nПобедил вариант {update.SelectedOption.DisplayOrder}: <b>{update.SelectedOption.ProposedAt.FormatMoscow()}</b> (МСК)."
|
||||
: $"❌ <b>Голосование завершено.</b>\n{System.Net.WebUtility.HtmlEncode(update.Decision.Reason)}";
|
||||
|
||||
var text = $"""
|
||||
{HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||
update.Title,
|
||||
update.CurrentScheduledAt,
|
||||
update.VotingDeadlineAt,
|
||||
update.Options,
|
||||
update.Participants,
|
||||
update.Votes)}
|
||||
|
||||
{resultText}
|
||||
""";
|
||||
|
||||
return bot.EditMessageText(
|
||||
chatId: ParseLong(update.ExistingMessage.ExternalGroupId),
|
||||
messageId: ParseInt(update.ExistingMessage.ExternalMessageId),
|
||||
text: text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
private async Task<Message> SendScheduleTextMessage(
|
||||
long chatId,
|
||||
int? threadId,
|
||||
string text,
|
||||
InlineKeyboardMarkup markup,
|
||||
CancellationToken ct) =>
|
||||
await bot.SendMessage(
|
||||
chatId: chatId,
|
||||
messageThreadId: threadId,
|
||||
text: text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: markup,
|
||||
cancellationToken: ct);
|
||||
|
||||
private static string BuildConfirmationText(PlatformConfirmationRequest request)
|
||||
{
|
||||
var confirmed = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Confirmed).ToList();
|
||||
var declined = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Declined).ToList();
|
||||
var pending = request.Participants.Where(p => p.RsvpStatus == RsvpStatus.Pending).ToList();
|
||||
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"🎲 Подтвердите участие в «{System.Net.WebUtility.HtmlEncode(request.Title)}»",
|
||||
$"📅 {request.ScheduledAt.FormatMoscow()} (МСК)",
|
||||
string.Empty
|
||||
};
|
||||
|
||||
foreach (var participant in confirmed)
|
||||
{
|
||||
lines.Add($" ✅ {FormatTelegramParticipant(participant)}");
|
||||
}
|
||||
|
||||
foreach (var participant in declined)
|
||||
{
|
||||
lines.Add($" ❌ <s>{FormatTelegramParticipant(participant)}</s>");
|
||||
}
|
||||
|
||||
foreach (var participant in pending)
|
||||
{
|
||||
lines.Add($" ⏳ {FormatTelegramParticipant(participant)}");
|
||||
}
|
||||
|
||||
lines.Add(string.Empty);
|
||||
|
||||
if (request.Participants.Count > 0 && confirmed.Count == request.Participants.Count)
|
||||
{
|
||||
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{request.Participants.Count})");
|
||||
}
|
||||
else if (declined.Count > 0)
|
||||
{
|
||||
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{request.Participants.Count} подтвердили)");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{request.Participants.Count})");
|
||||
}
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private static string BuildJoinLinkText(PlatformJoinLinkNotification notification)
|
||||
{
|
||||
var mentions = string.Join(", ", notification.ConfirmedPlayers.Select(FormatTelegramParticipant));
|
||||
|
||||
return $"""
|
||||
🎮 Игра «{notification.Title}» начинается через 5 минут!
|
||||
|
||||
🔗 Ссылка на подключение:
|
||||
{notification.JoinLink}
|
||||
|
||||
Участники: {mentions}
|
||||
|
||||
Хорошей игры! 🎲
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildDirectNotificationText(PlatformDirectSessionNotification notification) =>
|
||||
notification.Kind switch
|
||||
{
|
||||
PlatformDirectSessionNotificationKind.ConfirmationRequest => $"""
|
||||
🎲 <b>Подтвердите участие в игре</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
|
||||
|
||||
Ответьте кнопкой в групповом сообщении расписания.
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
|
||||
⏰ <b>Игра начнётся примерно через 1 час</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.JoinLink => $"""
|
||||
🎮 <b>Игра начинается через 5 минут</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
|
||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 Новое время: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
""",
|
||||
PlatformDirectSessionNotificationKind.RescheduleRejected => $"""
|
||||
❌ <b>Перенос сессии отклонён по итогам голосования</b>
|
||||
|
||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
||||
📅 Время остаётся прежним: <b>{notification.ScheduledAt.FormatMoscow()}</b> (МСК)
|
||||
Причина: {System.Net.WebUtility.HtmlEncode(notification.Reason ?? string.Empty)}
|
||||
""",
|
||||
_ => BuildFallbackDirectText(notification)
|
||||
};
|
||||
|
||||
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
|
||||
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
|
||||
|
||||
private static string BuildRsvpOutcomeDirectText(PlatformRsvpOutcomeNotification notification) =>
|
||||
notification.Kind switch
|
||||
{
|
||||
PlatformRsvpOutcomeKind.GmAllConfirmed =>
|
||||
$"✅ Все подтвердили участие в «{System.Net.WebUtility.HtmlEncode(notification.Title)}» ({notification.ScheduledAt.FormatMoscow()} МСК).",
|
||||
PlatformRsvpOutcomeKind.GmPlayerDeclined =>
|
||||
$"🚨 Отмена! {System.Net.WebUtility.HtmlEncode(notification.ActorDisplayName ?? "Игрок")} не сможет прийти на игру «{System.Net.WebUtility.HtmlEncode(notification.Title)}».",
|
||||
_ => System.Net.WebUtility.HtmlEncode(notification.Title)
|
||||
};
|
||||
|
||||
private static InlineKeyboardMarkup BuildRsvpKeyboard(Guid sessionId) =>
|
||||
new([
|
||||
[
|
||||
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{sessionId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{sessionId}")
|
||||
]
|
||||
]);
|
||||
|
||||
private static string FormatTelegramParticipant(PlatformSessionParticipant participant) =>
|
||||
participant.User.ExternalUsername is not null
|
||||
? $"@{participant.User.ExternalUsername}"
|
||||
: System.Net.WebUtility.HtmlEncode(participant.User.DisplayName);
|
||||
|
||||
private async Task TrySendScheduleImageOnly(
|
||||
long chatId,
|
||||
int? threadId,
|
||||
string title,
|
||||
string imageReference,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await bot.SendPhoto(
|
||||
chatId: chatId,
|
||||
messageThreadId: threadId,
|
||||
photo: InputFile.FromString(imageReference),
|
||||
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send Telegram schedule image for chat {ChatId}", chatId);
|
||||
}
|
||||
}
|
||||
|
||||
private static InlineKeyboardMarkup? BuildActionsMarkup(IReadOnlyList<PlatformMessageAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new InlineKeyboardMarkup(
|
||||
actions.Select(action => new[]
|
||||
{
|
||||
Uri.TryCreate(action.Payload, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
|
||||
? InlineKeyboardButton.WithUrl(action.Label, action.Payload)
|
||||
: InlineKeyboardButton.WithCallbackData(action.Label, action.Payload)
|
||||
}));
|
||||
}
|
||||
|
||||
private static void EnsureTelegram(PlatformKind platform)
|
||||
{
|
||||
if (platform != PlatformKind.Telegram)
|
||||
{
|
||||
throw new NotSupportedException($"Telegram messenger cannot send messages for platform {platform}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static long ParseLong(string value) => long.Parse(value, CultureInfo.InvariantCulture);
|
||||
|
||||
private static int ParseInt(string value) => int.Parse(value, CultureInfo.InvariantCulture);
|
||||
|
||||
private static int? ParseNullableInt(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : int.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// NOTE: duplicated in GmRelay.Web/Services/TelegramSessionBatchRenderer.cs
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
public static class TelegramSessionBatchRenderer
|
||||
{
|
||||
public static (string Text, InlineKeyboardMarkup Markup) Render(SessionBatchViewModel view)
|
||||
{
|
||||
var messageText = $"🎲 <b>Новые игры:</b> {System.Net.WebUtility.HtmlEncode(view.Title)}\n\n" +
|
||||
$"<b>Расписание:</b>\n\n";
|
||||
|
||||
var buttons = new List<InlineKeyboardButton[]>();
|
||||
|
||||
foreach (var session in view.Sessions)
|
||||
{
|
||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||
messageText += session.MaxPlayers.HasValue
|
||||
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
||||
|
||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||
{
|
||||
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
|
||||
}
|
||||
|
||||
if (session.ActivePlayers.Count > 0)
|
||||
{
|
||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||
$" 👤 {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += " <i>Пока никто не записался</i>\n";
|
||||
}
|
||||
|
||||
if (session.WaitlistedPlayers.Count > 0)
|
||||
{
|
||||
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
|
||||
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
||||
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
|
||||
if (GmRelay.Shared.Domain.SessionStatus.IsCancelled(session.Status))
|
||||
{
|
||||
messageText += "❌ <i>Сессия отменена</i>\n\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
messageText += "\n";
|
||||
var actionRow = session.AvailableActions
|
||||
.Select(a => InlineKeyboardButton.WithCallbackData(a.Label, $"{a.ActionKey}:{a.SessionId}"))
|
||||
.ToArray();
|
||||
if (actionRow.Length > 0)
|
||||
buttons.Add(actionRow);
|
||||
}
|
||||
}
|
||||
|
||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
public sealed record TelegramTopicDestination(
|
||||
int? MessageThreadId,
|
||||
bool ShouldCreateForumTopic,
|
||||
bool TopicCreatedByBot);
|
||||
|
||||
public static class TelegramTopicRouting
|
||||
{
|
||||
public const string MissingForumTopicRightsMessage =
|
||||
"Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите команду.";
|
||||
|
||||
public static TelegramTopicDestination ResolveNewScheduleDestination(
|
||||
bool chatIsForum,
|
||||
int? incomingMessageThreadId)
|
||||
{
|
||||
if (!chatIsForum)
|
||||
{
|
||||
return new TelegramTopicDestination(null, ShouldCreateForumTopic: false, TopicCreatedByBot: false);
|
||||
}
|
||||
|
||||
if (incomingMessageThreadId.HasValue)
|
||||
{
|
||||
return new TelegramTopicDestination(
|
||||
incomingMessageThreadId,
|
||||
ShouldCreateForumTopic: false,
|
||||
TopicCreatedByBot: false);
|
||||
}
|
||||
|
||||
return new TelegramTopicDestination(null, ShouldCreateForumTopic: true, TopicCreatedByBot: true);
|
||||
}
|
||||
|
||||
public static bool ShouldDeleteForumTopic(bool topicCreatedByBot, int remainingSessionsInTopic) =>
|
||||
topicCreatedByBot && remainingSessionsInTopic == 0;
|
||||
|
||||
public static bool IsMissingForumTopicRightsError(string apiError) =>
|
||||
apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
|
||||
apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
|
||||
apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
||||
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
||||
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
|
||||
using GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
@@ -18,17 +23,19 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
/// </summary>
|
||||
public sealed class UpdateRouter(
|
||||
HandleRsvpHandler rsvpHandler,
|
||||
CreateSessionHandler createSessionHandler,
|
||||
BotCreateSessionHandler createSessionHandler,
|
||||
JoinSessionHandler joinSessionHandler,
|
||||
LeaveSessionHandler leaveSessionHandler,
|
||||
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
||||
CancelSessionHandler cancelSessionHandler,
|
||||
DeleteSessionHandler deleteSessionHandler,
|
||||
ListSessionsHandler listSessionsHandler,
|
||||
ExportCalendarHandler exportCalendarHandler,
|
||||
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
||||
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||||
ITelegramBotClient bot,
|
||||
IConfiguration configuration,
|
||||
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||||
{
|
||||
public async Task RouteAsync(Update update, CancellationToken ct)
|
||||
@@ -39,17 +46,26 @@ public sealed class UpdateRouter(
|
||||
await HandleCallbackQueryAsync(query, ct);
|
||||
break;
|
||||
|
||||
case { Message: { Text: { } text } message } when text.StartsWith('/'):
|
||||
await HandleCommandAsync(message, text, ct);
|
||||
break;
|
||||
case { Message: { } message }:
|
||||
var commandText = GetCommandText(message);
|
||||
if (commandText.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
await HandleCommandAsync(message, commandText, ct);
|
||||
break;
|
||||
}
|
||||
|
||||
if (message.Text is not null)
|
||||
{
|
||||
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
|
||||
}
|
||||
|
||||
// Non-command text messages — check for reschedule time input
|
||||
case { Message: { Text: { } } message } when !message.Text!.StartsWith('/'):
|
||||
await rescheduleTimeInputHandler.TryHandleAsync(message, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string GetCommandText(Message message)
|
||||
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
|
||||
|
||||
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
|
||||
{
|
||||
if (query.Data is not { } data || query.Message is not { } message)
|
||||
@@ -57,22 +73,39 @@ public sealed class UpdateRouter(
|
||||
|
||||
var parts = data.Split(':', 3);
|
||||
var action = parts[0];
|
||||
var user = TelegramPlatformIds.User(
|
||||
query.From.Id,
|
||||
query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
||||
query.From.Username);
|
||||
var group = TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title);
|
||||
var scheduleMessage = TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, message.MessageId);
|
||||
|
||||
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
|
||||
{
|
||||
var command = new JoinSessionCommand(
|
||||
SessionId: joinSessionId,
|
||||
TelegramUserId: query.From.Id,
|
||||
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
||||
TelegramUsername: query.From.Username,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
MessageId: message.MessageId);
|
||||
|
||||
User: user,
|
||||
InteractionId: query.Id,
|
||||
Group: group,
|
||||
ScheduleMessage: scheduleMessage);
|
||||
|
||||
await joinSessionHandler.HandleAsync(command, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == "leave_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var leaveSessionId))
|
||||
{
|
||||
var command = new LeaveSessionCommand(
|
||||
SessionId: leaveSessionId,
|
||||
User: user,
|
||||
InteractionId: query.Id,
|
||||
Group: group,
|
||||
ScheduleMessage: scheduleMessage);
|
||||
|
||||
await leaveSessionHandler.HandleAsync(command, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == "cancel_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var cancelSessionId))
|
||||
{
|
||||
var command = new CancelSessionCommand(
|
||||
@@ -80,6 +113,7 @@ public sealed class UpdateRouter(
|
||||
TelegramUserId: query.From.Id,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
MessageThreadId: message.MessageThreadId,
|
||||
MessageId: message.MessageId);
|
||||
|
||||
await cancelSessionHandler.HandleAsync(command, ct);
|
||||
@@ -119,21 +153,17 @@ public sealed class UpdateRouter(
|
||||
TelegramUserId: query.From.Id,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
MessageThreadId: message.MessageThreadId,
|
||||
MessageId: message.MessageId);
|
||||
|
||||
await initiateRescheduleHandler.HandleAsync(command, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == "reschedule_vote" && parts.Length >= 3 && Guid.TryParse(parts[2], out var proposalId))
|
||||
if (action == "reschedule_vote" && parts.Length >= 2 && Guid.TryParse(parts[1], out var optionId))
|
||||
{
|
||||
var vote = parts[1]; // "yes" or "no"
|
||||
if (vote is not ("yes" or "no"))
|
||||
return;
|
||||
|
||||
var command = new HandleRescheduleVoteCommand(
|
||||
ProposalId: proposalId,
|
||||
Vote: vote,
|
||||
OptionId: optionId,
|
||||
TelegramUserId: query.From.Id,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
@@ -160,11 +190,11 @@ public sealed class UpdateRouter(
|
||||
|
||||
var command = new HandleRsvpCommand(
|
||||
SessionId: sessionId,
|
||||
TelegramUserId: query.From.Id,
|
||||
User: user,
|
||||
Status: status,
|
||||
CallbackQueryId: query.Id,
|
||||
ChatId: message.Chat.Id,
|
||||
MessageId: message.MessageId);
|
||||
InteractionId: query.Id,
|
||||
Group: group,
|
||||
ConfirmationMessage: scheduleMessage);
|
||||
|
||||
await rsvpHandler.HandleAsync(command, ct);
|
||||
}
|
||||
@@ -179,10 +209,7 @@ public sealed class UpdateRouter(
|
||||
switch (command)
|
||||
{
|
||||
case "/start":
|
||||
await bot.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "GM-Relay Bot ready. Use /help for commands.",
|
||||
cancellationToken: ct);
|
||||
await SendStartMessageAsync(message, ct);
|
||||
break;
|
||||
|
||||
case "/newsession":
|
||||
@@ -208,8 +235,15 @@ public sealed class UpdateRouter(
|
||||
Время: 15.05.2026 19:30
|
||||
Мест: 4
|
||||
Ссылка: https://link
|
||||
Картинка: https://cover
|
||||
|
||||
Для регулярного расписания можно указать одну дату:
|
||||
Игр: 4
|
||||
Интервал: 7
|
||||
|
||||
/listsessions — список предстоящих сессий
|
||||
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
|
||||
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
||||
/help — эта справка
|
||||
""",
|
||||
cancellationToken: ct);
|
||||
@@ -221,4 +255,24 @@ public sealed class UpdateRouter(
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
|
||||
{
|
||||
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
|
||||
if (string.IsNullOrWhiteSpace(miniAppUrl))
|
||||
{
|
||||
await bot.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "GM-Relay Bot ready. Use /help for commands.",
|
||||
cancellationToken: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await bot.SendMessage(
|
||||
chatId: message.Chat.Id,
|
||||
text: "GM-Relay Bot ready. Откройте dashboard внутри Telegram или используйте /help для команд.",
|
||||
replyMarkup: new InlineKeyboardMarkup(
|
||||
InlineKeyboardButton.WithWebApp("Открыть dashboard", new WebAppInfo(miniAppUrl))),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN notification_mode VARCHAR(50) NOT NULL DEFAULT 'GroupAndDirect'
|
||||
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
|
||||
ADD COLUMN one_hour_reminder_processed_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX ix_sessions_one_hour_reminders ON sessions (scheduled_at)
|
||||
WHERE status IN ('Confirmed', 'ConfirmationSent')
|
||||
AND one_hour_reminder_processed_at IS NULL;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Add explicit owner/co-GM management roles for each Telegram group.
|
||||
|
||||
INSERT INTO players (telegram_id, display_name)
|
||||
SELECT DISTINCT gg.gm_telegram_id,
|
||||
'GM ' || gg.gm_telegram_id::text
|
||||
FROM game_groups gg
|
||||
ON CONFLICT (telegram_id) DO NOTHING;
|
||||
|
||||
CREATE TABLE group_managers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
role VARCHAR(50) NOT NULL CHECK (role IN ('Owner', 'CoGm')),
|
||||
added_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (group_id, player_id)
|
||||
);
|
||||
|
||||
INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT gg.id, p.id, 'Owner'
|
||||
FROM game_groups gg
|
||||
JOIN players p ON p.telegram_id = gg.gm_telegram_id
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING;
|
||||
|
||||
CREATE INDEX ix_group_managers_group_role ON group_managers (group_id, role);
|
||||
CREATE INDEX ix_group_managers_player ON group_managers (player_id);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Multi-option reschedule voting with a deadline.
|
||||
|
||||
ALTER TABLE reschedule_proposals
|
||||
ADD COLUMN voting_deadline_at TIMESTAMPTZ,
|
||||
ADD COLUMN selected_option_id UUID;
|
||||
|
||||
CREATE TABLE reschedule_options (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
|
||||
proposed_at TIMESTAMPTZ NOT NULL,
|
||||
display_order INTEGER NOT NULL CHECK (display_order BETWEEN 1 AND 3),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (proposal_id, id),
|
||||
UNIQUE (proposal_id, display_order),
|
||||
UNIQUE (proposal_id, proposed_at)
|
||||
);
|
||||
|
||||
CREATE TABLE reschedule_option_votes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
proposal_id UUID NOT NULL REFERENCES reschedule_proposals(id) ON DELETE CASCADE,
|
||||
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
option_id UUID NOT NULL,
|
||||
voted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (proposal_id, player_id),
|
||||
FOREIGN KEY (proposal_id, option_id)
|
||||
REFERENCES reschedule_options(proposal_id, id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX ix_reschedule_voting_deadline
|
||||
ON reschedule_proposals (voting_deadline_at)
|
||||
WHERE status = 'Voting';
|
||||
|
||||
CREATE INDEX ix_reschedule_option_votes_option
|
||||
ON reschedule_option_votes (option_id);
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE campaign_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
join_link TEXT NOT NULL,
|
||||
session_count INTEGER NOT NULL CHECK (session_count BETWEEN 1 AND 52),
|
||||
interval_days INTEGER NOT NULL CHECK (interval_days BETWEEN 1 AND 365),
|
||||
max_players INTEGER CHECK (max_players IS NULL OR max_players > 0),
|
||||
notification_mode VARCHAR(32) NOT NULL DEFAULT 'GroupAndDirect'
|
||||
CHECK (notification_mode IN ('GroupAndDirect', 'GroupOnly')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (group_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_campaign_templates_group ON campaign_templates (group_id, created_at DESC);
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE calendar_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
user_telegram_id BIGINT NOT NULL,
|
||||
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
filter_type SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX ix_calendar_subscriptions_user_telegram_id ON calendar_subscriptions (user_telegram_id);
|
||||
@@ -0,0 +1,66 @@
|
||||
-- =============================================================
|
||||
-- Attendance statistics view for GM analytics
|
||||
-- Returns per-player aggregated metrics for a given game group.
|
||||
-- NOTE: waitlist count reflects CURRENT registration_status only.
|
||||
-- Full historical waitlist tracking will come with #15.
|
||||
-- =============================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID)
|
||||
RETURNS TABLE (
|
||||
player_id UUID,
|
||||
display_name VARCHAR,
|
||||
telegram_username VARCHAR,
|
||||
total_sessions BIGINT,
|
||||
confirmed_count BIGINT,
|
||||
declined_count BIGINT,
|
||||
no_response_count BIGINT,
|
||||
waitlisted_count BIGINT,
|
||||
cancellation_affected_count BIGINT,
|
||||
attendance_rate NUMERIC
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH player_sessions AS (
|
||||
SELECT
|
||||
sp.player_id,
|
||||
s.id AS session_id,
|
||||
sp.rsvp_status,
|
||||
sp.registration_status,
|
||||
s.status AS session_status,
|
||||
s.scheduled_at
|
||||
FROM session_participants sp
|
||||
JOIN sessions s ON s.id = sp.session_id
|
||||
WHERE s.group_id = p_group_id
|
||||
),
|
||||
player_totals AS (
|
||||
SELECT
|
||||
ps.player_id,
|
||||
COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions,
|
||||
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count,
|
||||
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count,
|
||||
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count,
|
||||
COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count,
|
||||
COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count
|
||||
FROM player_sessions ps
|
||||
GROUP BY ps.player_id
|
||||
)
|
||||
SELECT
|
||||
pt.player_id,
|
||||
p.display_name,
|
||||
COALESCE(p.external_username, p.telegram_username) AS telegram_username,
|
||||
pt.total_sessions,
|
||||
pt.confirmed_count,
|
||||
pt.declined_count,
|
||||
pt.no_response_count,
|
||||
pt.waitlisted_count,
|
||||
pt.cancellation_affected_count,
|
||||
ROUND(
|
||||
100.0 * pt.confirmed_count
|
||||
/ NULLIF(pt.total_sessions, 0),
|
||||
1
|
||||
) AS attendance_rate
|
||||
FROM player_totals pt
|
||||
JOIN players p ON p.id = pt.player_id
|
||||
ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE session_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
actor_telegram_id BIGINT NOT NULL,
|
||||
actor_name VARCHAR(255) NOT NULL,
|
||||
change_type VARCHAR(50) NOT NULL
|
||||
CHECK (change_type IN ('Title','Time','Link','MaxPlayers','Status','WaitlistPromote','PlayerRemoved','BatchRescheduled','Cancelled')),
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ix_session_audit_log_session_id ON session_audit_log(session_id);
|
||||
CREATE INDEX ix_session_audit_log_changed_at ON session_audit_log(changed_at);
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN confirmation_sent_at TIMESTAMPTZ;
|
||||
|
||||
-- Update existing ConfirmationSent sessions to have a sentinel value
|
||||
-- so they don't get re-processed after migration
|
||||
UPDATE sessions
|
||||
SET confirmation_sent_at = now()
|
||||
WHERE status = 'ConfirmationSent';
|
||||
|
||||
-- Partial index for efficient T-24h query
|
||||
CREATE INDEX ix_sessions_confirmation_reminders ON sessions (scheduled_at)
|
||||
WHERE status = 'Planned'
|
||||
AND confirmation_sent_at IS NULL;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN topic_created_by_bot BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
UPDATE sessions
|
||||
SET topic_created_by_bot = TRUE
|
||||
WHERE thread_id IS NOT NULL;
|
||||
@@ -0,0 +1,119 @@
|
||||
-- =============================================================
|
||||
-- V016: Add platform identity columns and platform_messages table
|
||||
-- =============================================================
|
||||
-- Scope: Prepare schema for multi-platform support (Discord, etc).
|
||||
-- Legacy telegram_* columns are retained for backward compatibility.
|
||||
-- =============================================================
|
||||
|
||||
-- -- Players: platform-agnostic identity
|
||||
ALTER TABLE players
|
||||
ADD COLUMN platform VARCHAR(50),
|
||||
ADD COLUMN external_user_id VARCHAR(255),
|
||||
ADD COLUMN external_username VARCHAR(255);
|
||||
|
||||
CREATE UNIQUE INDEX ix_players_platform_external_user_id
|
||||
ON players (platform, external_user_id)
|
||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL;
|
||||
|
||||
-- -- Game groups: platform-agnostic identity
|
||||
ALTER TABLE game_groups
|
||||
ADD COLUMN platform VARCHAR(50),
|
||||
ADD COLUMN external_group_id VARCHAR(255),
|
||||
ADD COLUMN external_channel_id VARCHAR(255);
|
||||
|
||||
CREATE UNIQUE INDEX ix_game_groups_platform_external_group_id
|
||||
ON game_groups (platform, external_group_id)
|
||||
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL;
|
||||
|
||||
-- -- Backfill existing Telegram data
|
||||
UPDATE players
|
||||
SET platform = 'Telegram',
|
||||
external_user_id = telegram_id::TEXT,
|
||||
external_username = telegram_username
|
||||
WHERE platform IS NULL;
|
||||
|
||||
UPDATE game_groups
|
||||
SET platform = 'Telegram',
|
||||
external_group_id = telegram_chat_id::TEXT
|
||||
WHERE platform IS NULL;
|
||||
|
||||
-- -- Platform messages: store per-platform message references
|
||||
CREATE TABLE platform_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform VARCHAR(50) NOT NULL,
|
||||
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||
batch_id UUID,
|
||||
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
external_channel_id VARCHAR(255),
|
||||
external_thread_id VARCHAR(255),
|
||||
external_message_id VARCHAR(255) NOT NULL,
|
||||
purpose VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ix_platform_messages_group_id ON platform_messages(group_id);
|
||||
CREATE INDEX ix_platform_messages_batch_id ON platform_messages(batch_id);
|
||||
CREATE INDEX ix_platform_messages_session_id ON platform_messages(session_id);
|
||||
CREATE INDEX ix_platform_messages_platform_message
|
||||
ON platform_messages (platform, external_message_id);
|
||||
|
||||
-- -- Recreate attendance stats function for new columns (prod back-compat)
|
||||
CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID)
|
||||
RETURNS TABLE (
|
||||
player_id UUID,
|
||||
display_name VARCHAR,
|
||||
telegram_username VARCHAR,
|
||||
total_sessions BIGINT,
|
||||
confirmed_count BIGINT,
|
||||
declined_count BIGINT,
|
||||
no_response_count BIGINT,
|
||||
waitlisted_count BIGINT,
|
||||
cancellation_affected_count BIGINT,
|
||||
attendance_rate NUMERIC
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH player_sessions AS (
|
||||
SELECT
|
||||
sp.player_id,
|
||||
s.id AS session_id,
|
||||
sp.rsvp_status,
|
||||
sp.registration_status,
|
||||
s.status AS session_status,
|
||||
s.scheduled_at
|
||||
FROM session_participants sp
|
||||
JOIN sessions s ON s.id = sp.session_id
|
||||
WHERE s.group_id = p_group_id
|
||||
),
|
||||
player_totals AS (
|
||||
SELECT
|
||||
ps.player_id,
|
||||
COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions,
|
||||
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count,
|
||||
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count,
|
||||
COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count,
|
||||
COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count,
|
||||
COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count
|
||||
FROM player_sessions ps
|
||||
GROUP BY ps.player_id
|
||||
)
|
||||
SELECT
|
||||
pt.player_id,
|
||||
p.display_name,
|
||||
COALESCE(p.external_username, p.telegram_username) AS telegram_username,
|
||||
pt.total_sessions,
|
||||
pt.confirmed_count,
|
||||
pt.declined_count,
|
||||
pt.no_response_count,
|
||||
pt.waitlisted_count,
|
||||
pt.cancellation_affected_count,
|
||||
ROUND(
|
||||
100.0 * pt.confirmed_count
|
||||
/ NULLIF(pt.total_sessions, 0),
|
||||
1
|
||||
) AS attendance_rate
|
||||
FROM player_totals pt
|
||||
JOIN players p ON p.id = pt.player_id
|
||||
ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- =============================================================
|
||||
-- V017: Allow platform-neutral players
|
||||
-- =============================================================
|
||||
-- Legacy Telegram identity columns remain for backward compatibility,
|
||||
-- but non-Telegram platform users do not have Telegram ids.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE players
|
||||
ALTER COLUMN telegram_id DROP NOT NULL;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- =============================================================
|
||||
-- V018: Add platform columns to reschedule_proposals
|
||||
-- =============================================================
|
||||
-- Add platform columns to reschedule_proposals to support Discord reschedule voting.
|
||||
-- proposed_by is made nullable so Discord proposals can leave it NULL
|
||||
-- (Discord snowflakes don't fit in BIGINT safely).
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE reschedule_proposals
|
||||
ALTER COLUMN proposed_by DROP NOT NULL;
|
||||
|
||||
ALTER TABLE reschedule_proposals
|
||||
ADD COLUMN source_platform VARCHAR(50),
|
||||
ADD COLUMN proposed_by_external_user_id VARCHAR(255);
|
||||
|
||||
UPDATE reschedule_proposals
|
||||
SET source_platform = 'Telegram',
|
||||
proposed_by_external_user_id = proposed_by::TEXT
|
||||
WHERE source_platform IS NULL;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- =============================================================
|
||||
-- V019: Rename session_audit_log.actor_telegram_id to actor_external_user_id
|
||||
-- =============================================================
|
||||
-- Scope: Support platform-agnostic audit log identity.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE session_audit_log
|
||||
ADD COLUMN actor_external_user_id VARCHAR(255);
|
||||
|
||||
UPDATE session_audit_log
|
||||
SET actor_external_user_id = actor_telegram_id::TEXT
|
||||
WHERE actor_external_user_id IS NULL;
|
||||
|
||||
ALTER TABLE session_audit_log
|
||||
ALTER COLUMN actor_external_user_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE session_audit_log
|
||||
DROP COLUMN actor_telegram_id;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- =============================================================
|
||||
-- V020: Player identity linking for unified multi-platform accounts
|
||||
-- =============================================================
|
||||
-- Scope: Allow linking multiple platform identities (Telegram, Discord)
|
||||
-- to a single "primary" player account. All group/session permissions
|
||||
-- resolve through the effective (primary) player id.
|
||||
-- =============================================================
|
||||
|
||||
-- player_links: secondary player → primary player (1:1 on secondary)
|
||||
CREATE TABLE player_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
primary_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
secondary_player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
|
||||
linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
linked_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
|
||||
-- Prevent self-linking at the DB level
|
||||
CONSTRAINT no_self_link CHECK (primary_player_id <> secondary_player_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_player_links_primary_player_id
|
||||
ON player_links(primary_player_id);
|
||||
|
||||
-- identity_audit_log: security-sensitive link/unlink actions
|
||||
CREATE TABLE identity_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
action VARCHAR(50) NOT NULL, -- 'link', 'unlink', 'link_attempt_conflict'
|
||||
target_platform VARCHAR(50),
|
||||
target_external_user_id VARCHAR(255),
|
||||
performed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
performed_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_identity_audit_log_player_id
|
||||
ON identity_audit_log(player_id);
|
||||
CREATE INDEX ix_identity_audit_log_performed_at
|
||||
ON identity_audit_log(performed_at DESC);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- =============================================================
|
||||
-- V021: Add avatar_url column to players table
|
||||
-- =============================================================
|
||||
-- Scope: Support storing avatar URLs for Discord and other platforms.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE players
|
||||
ADD COLUMN avatar_url VARCHAR(500);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- =============================================================
|
||||
-- V022: Fix incorrectly oriented player_links for Discord↔Telegram
|
||||
-- =============================================================
|
||||
-- Scope: Reverse player_links where Discord was incorrectly made primary
|
||||
-- and Telegram secondary. Telegram (with historical group/session data)
|
||||
-- must always be the primary account.
|
||||
-- =============================================================
|
||||
|
||||
UPDATE player_links pl
|
||||
SET primary_player_id = pl.secondary_player_id,
|
||||
secondary_player_id = pl.primary_player_id
|
||||
FROM players p1, players p2
|
||||
WHERE pl.primary_player_id = p1.id
|
||||
AND pl.secondary_player_id = p2.id
|
||||
AND p1.platform = 'Discord'
|
||||
AND p2.platform = 'Telegram';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- =============================================================
|
||||
-- V023: Make legacy Telegram columns nullable for multi-platform
|
||||
-- =============================================================
|
||||
-- Scope: Allow Discord (and future platforms) to create players
|
||||
-- and game_groups without legacy telegram_* values.
|
||||
-- Existing Telegram data was backfilled in V016.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE game_groups
|
||||
ALTER COLUMN telegram_chat_id DROP NOT NULL,
|
||||
ALTER COLUMN gm_telegram_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE players
|
||||
ALTER COLUMN telegram_id DROP NOT NULL;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- =============================================================
|
||||
-- V024: Deprecate legacy Telegram-specific columns
|
||||
-- =============================================================
|
||||
-- Scope: Complete platform migration by backfilling any remaining
|
||||
-- external_* gaps and officially deprecating telegram_* columns.
|
||||
-- No columns are dropped — rollback-safe.
|
||||
-- =============================================================
|
||||
|
||||
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
|
||||
UPDATE players
|
||||
SET platform = 'Telegram',
|
||||
external_user_id = telegram_id::TEXT,
|
||||
external_username = telegram_username
|
||||
WHERE platform IS NULL;
|
||||
|
||||
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
|
||||
UPDATE game_groups
|
||||
SET platform = 'Telegram',
|
||||
external_group_id = telegram_chat_id::TEXT
|
||||
WHERE platform IS NULL;
|
||||
|
||||
-- 3. Add platform identity to calendar_subscriptions
|
||||
ALTER TABLE calendar_subscriptions
|
||||
ADD COLUMN user_platform VARCHAR(50),
|
||||
ADD COLUMN user_external_id VARCHAR(255);
|
||||
|
||||
UPDATE calendar_subscriptions
|
||||
SET user_external_id = user_telegram_id::TEXT,
|
||||
user_platform = 'Telegram'
|
||||
WHERE user_platform IS NULL;
|
||||
|
||||
-- 4. Migrate calendar subscription index
|
||||
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
|
||||
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
|
||||
|
||||
-- 5. Deprecation comments on legacy columns
|
||||
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
|
||||
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
|
||||
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
|
||||
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
|
||||
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- =============================================================
|
||||
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
|
||||
-- =============================================================
|
||||
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
|
||||
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
|
||||
-- =============================================================
|
||||
|
||||
UPDATE reschedule_proposals
|
||||
SET proposed_by_external_user_id = proposed_by::TEXT
|
||||
WHERE proposed_by_external_user_id IS NULL
|
||||
AND proposed_by IS NOT NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Public club pages and read-only schedule publication controls.
|
||||
|
||||
ALTER TABLE game_groups
|
||||
ADD COLUMN public_slug VARCHAR(120),
|
||||
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE UNIQUE INDEX ux_game_groups_public_slug
|
||||
ON game_groups (lower(public_slug))
|
||||
WHERE public_slug IS NOT NULL;
|
||||
|
||||
CREATE INDEX ix_sessions_public_schedule
|
||||
ON sessions (group_id, scheduled_at)
|
||||
WHERE is_public = true AND status <> 'Cancelled';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Showcase fields for game catalog / public session browsing.
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN system VARCHAR(50),
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN cover_image_url TEXT,
|
||||
ADD COLUMN duration_minutes INTEGER,
|
||||
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
|
||||
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX ix_sessions_showcase
|
||||
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||
WHERE is_public = true AND status <> 'Cancelled';
|
||||
@@ -1,12 +1,19 @@
|
||||
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Bot.Features.Notifications;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Bot.Infrastructure.Database;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Bot.Infrastructure.Health;
|
||||
using GmRelay.Bot.Infrastructure.Logging;
|
||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||
using GmRelay.Bot.Infrastructure.Telegram;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using Telegram.Bot;
|
||||
|
||||
@@ -47,29 +54,56 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
return new TelegramBotClient(token);
|
||||
});
|
||||
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
||||
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
|
||||
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram));
|
||||
|
||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
|
||||
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
|
||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||
builder.Services.AddSingleton<CreateSessionHandler>();
|
||||
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
||||
builder.Services.AddSingleton<CancelSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
|
||||
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
||||
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
|
||||
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
||||
|
||||
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||
|
||||
// ── Telegram infrastructure ──────────────────────────────────────────
|
||||
builder.Services.AddSingleton<UpdateRouter>();
|
||||
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
|
||||
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
||||
builder.Services.AddHostedService<TelegramBotService>();
|
||||
|
||||
// ── Clock and scheduling ──────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<ISystemClock, GmRelay.Bot.Infrastructure.Scheduling.SystemClock>();
|
||||
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||
|
||||
// ── Session scheduler ────────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
|
||||
|
||||
// ── Health check server ──────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<BotHealthCheckHostedService>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
}
|
||||
},
|
||||
"Telegram": {
|
||||
"BotToken": ""
|
||||
"BotToken": "",
|
||||
"MiniAppUrl": ""
|
||||
},
|
||||
"Web": {
|
||||
"BaseUrl": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"Dapper": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.1.72, )",
|
||||
"resolved": "2.1.72",
|
||||
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
|
||||
},
|
||||
"Dapper.AOT": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.0.48, )",
|
||||
"resolved": "1.0.48",
|
||||
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
|
||||
},
|
||||
"dbup-postgresql": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.1, )",
|
||||
"resolved": "7.0.1",
|
||||
"contentHash": "mRnmENWWPuuMZ538gOd1mZnzucx6FQk0anmw3EABjGfcbp24FDb9QdGepYrDiaM8K9s5/gd49+5cmBOlniH/lg==",
|
||||
"dependencies": {
|
||||
"Npgsql": "10.0.1",
|
||||
"dbup-core": "6.1.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.DotNet.ILCompiler": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "yadTZIkStCVsG8nGwvfroSfBApPsgjQbodQyaIfp53dgayE0qhZpywixiCB6lx57JYQ+KVg1m1AFLrj54pxpZg=="
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "A+5ZuQ0f449tM+MQrhf6R9ZX7lYpjk/ODEwLYKrnF6111rtARx8fVsm4YznUnQiKnnXfaXNBqgxmil6RW3L3SA=="
|
||||
},
|
||||
"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=="
|
||||
},
|
||||
"Telegram.Bot": {
|
||||
"type": "Direct",
|
||||
"requested": "[22.9.5.3, )",
|
||||
"resolved": "22.9.5.3",
|
||||
"contentHash": "7u8rZU9Vx9XEyIm6pB+dAlITsi1v63I+hKo7IEXGiQZnVjzvZgPs9yDCP17/Cwm7lgjCNEqknlbv/yoBnsUYFw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dbup-core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.1.1",
|
||||
"contentHash": "kgpuyJVEFJHoIj/slnc994Go88aoeZqNDfGHDBr4sh7CsEWwJhOTCt/FJqO4ziUImL5L0NEY0kxxOiNgPKI2Fw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
|
||||
}
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Binder": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
|
||||
}
|
||||
},
|
||||
"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.5",
|
||||
"contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Configuration.Binder": "10.0.5",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
|
||||
"Microsoft.Extensions.Options": "10.0.5",
|
||||
"Microsoft.Extensions.Primitives": "10.0.5"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"dependencies": {
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
|
||||
"Npgsql": "[10.0.2, )"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {
|
||||
"Microsoft.DotNet.ILCompiler": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.5, )",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "yadTZIkStCVsG8nGwvfroSfBApPsgjQbodQyaIfp53dgayE0qhZpywixiCB6lx57JYQ+KVg1m1AFLrj54pxpZg==",
|
||||
"dependencies": {
|
||||
"runtime.win-x64.Microsoft.DotNet.ILCompiler": "10.0.5"
|
||||
}
|
||||
},
|
||||
"runtime.win-x64.Microsoft.DotNet.ILCompiler": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "vblLkpVhSDYOmrEW0jypX7YVtLg7idU1QzUyx45ZdZ2sFUSSf3mYFCr0FW3+KZgXWpN1ve9ZPrxNywvHISF4bA=="
|
||||
},
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.5",
|
||||
"contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,25 @@
|
||||
# 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/aspnet:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Install wget for healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
USER $APP_UID
|
||||
ENTRYPOINT ["dotnet", "GmRelay.DiscordBot.dll"]
|
||||
@@ -0,0 +1,84 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record DiscordDeleteSessionResult(
|
||||
string ReplyText,
|
||||
SessionBatchViewModel? UpdatedView,
|
||||
string? EmptyMessage = null);
|
||||
|
||||
public sealed class DiscordDeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
DiscordListSessionsHandler listSessionsHandler,
|
||||
ILogger<DiscordDeleteSessionHandler> logger)
|
||||
{
|
||||
public async Task<DiscordDeleteSessionResult> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
Guid sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
{
|
||||
return new DiscordDeleteSessionResult(
|
||||
"Только owner, администратор или manager могут удалять сессии.",
|
||||
UpdatedView: null);
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
var deletedRows = await connection.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM sessions s
|
||||
USING game_groups g
|
||||
WHERE s.group_id = g.id
|
||||
AND s.id = @SessionId
|
||||
AND g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
""",
|
||||
new { SessionId = sessionId, GuildId = guildId },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
if (deletedRows == 0)
|
||||
{
|
||||
return new DiscordDeleteSessionResult(
|
||||
"Сессия не найдена или уже удалена.",
|
||||
UpdatedView: null);
|
||||
}
|
||||
|
||||
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||
|
||||
var updatedView = await listSessionsHandler.BuildScheduleAsync(
|
||||
guildId,
|
||||
channelId,
|
||||
userId,
|
||||
resolvedPermissions,
|
||||
guildOwnerId,
|
||||
cancellationToken);
|
||||
|
||||
return updatedView is null
|
||||
? new DiscordDeleteSessionResult(
|
||||
"Сессия удалена.",
|
||||
UpdatedView: null,
|
||||
EmptyMessage: "В этом сервере нет предстоящих игр.")
|
||||
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandContext>
|
||||
{
|
||||
private readonly DiscordListSessionsHandler _handler;
|
||||
|
||||
public DiscordListSessionsCommand(DiscordListSessionsHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
[SlashCommand("listsessions", "Show upcoming game sessions in this server")]
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
var guildId = Context.Interaction.GuildId?.ToString()
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
var channelId = Context.Channel.Id.ToString();
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||
var guildOwnerId = 0UL;
|
||||
|
||||
var view = await _handler.BuildScheduleAsync(
|
||||
guildId,
|
||||
channelId,
|
||||
Context.User.Id,
|
||||
resolvedPermissions,
|
||||
guildOwnerId,
|
||||
CancellationToken.None);
|
||||
|
||||
if (view is null)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message("📭 В этом сервере нет предстоящих игр."));
|
||||
return;
|
||||
}
|
||||
|
||||
var (embeds, actionRows) = Rendering.DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message(new InteractionMessageProperties()
|
||||
.WithEmbeds(embeds)
|
||||
.WithComponents(actionRows)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
internal sealed record DiscordSessionListItemDto(
|
||||
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
|
||||
int PlayerCount, int WaitlistCount);
|
||||
|
||||
public sealed class DiscordListSessionsHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker)
|
||||
{
|
||||
public Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
CancellationToken cancellationToken) =>
|
||||
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
|
||||
|
||||
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var sessions = await connection.QueryAsync<DiscordSessionListItemDto>(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status,
|
||||
s.max_players as MaxPlayers,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active)::int as PlayerCount,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted)::int as WaitlistCount
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
WHERE g.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId
|
||||
AND s.status != @Cancelled
|
||||
AND s.scheduled_at > now() - interval '4 hours'
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
GuildId = guildId,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
|
||||
var sessionList = sessions.ToList();
|
||||
if (sessionList.Count == 0)
|
||||
return null;
|
||||
|
||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
var canManage = permissionChecker.CanManageSchedule(
|
||||
guildOwnerId,
|
||||
userId,
|
||||
dbManagerUserIds,
|
||||
resolvedPermissions);
|
||||
|
||||
var sessionIds = sessionList.Select(s => s.Id).ToList();
|
||||
var participants = await connection.QueryAsync<ParticipantBatchDto>(
|
||||
@"SELECT sp.session_id as SessionId,
|
||||
p.display_name as DisplayName,
|
||||
p.external_username as TelegramUsername,
|
||||
sp.registration_status as RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = ANY(@SessionIds) AND sp.is_gm = false
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC",
|
||||
new { SessionIds = sessionIds });
|
||||
|
||||
var firstTitle = sessionList.First().Title;
|
||||
var batchDtos = sessionList.Select(s => new SessionBatchDto(
|
||||
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
|
||||
return canManage ? AddManagerActions(view) : view;
|
||||
}
|
||||
|
||||
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
|
||||
view with
|
||||
{
|
||||
Sessions = view.Sessions
|
||||
.Select(session =>
|
||||
{
|
||||
if (SessionStatus.IsCancelled(session.Status))
|
||||
return session;
|
||||
|
||||
var actions = session.AvailableActions
|
||||
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
|
||||
.ToList();
|
||||
|
||||
return session with { AvailableActions = actions };
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandContext>
|
||||
{
|
||||
private readonly DiscordNewSessionHandler _handler;
|
||||
private readonly ILogger<DiscordNewSessionCommand> _logger;
|
||||
|
||||
public DiscordNewSessionCommand(DiscordNewSessionHandler handler, ILogger<DiscordNewSessionCommand> logger)
|
||||
{
|
||||
_handler = handler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[SlashCommand("newsession", "Create a new game session")]
|
||||
public async Task ExecuteAsync(
|
||||
[SlashCommandParameter(Name = "title", Description = "Game title")] string title,
|
||||
[SlashCommandParameter(Name = "time", Description = "Session time (YYYY-MM-DD HH:mm or DD.MM.YYYY HH:mm)")] string time,
|
||||
[SlashCommandParameter(Name = "seats", Description = "Maximum number of players")] long? seats = null,
|
||||
[SlashCommandParameter(Name = "link", Description = "Join link")] string? link = null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"newsession called by user {UserId} ({UserType}) in guild {GuildId}, channel {ChannelId}",
|
||||
Context.User.Id,
|
||||
Context.User.GetType().Name,
|
||||
Context.Interaction.GuildId,
|
||||
Context.Channel?.Id);
|
||||
|
||||
var guildId = Context.Interaction.GuildId
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
if (member is null)
|
||||
{
|
||||
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
|
||||
throw new InvalidOperationException("Guild member data not available in interaction.");
|
||||
}
|
||||
|
||||
var resolvedPermissions = (ulong)member.Permissions;
|
||||
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
||||
|
||||
ulong guildOwnerId = 0;
|
||||
var guildName = guildId.ToString();
|
||||
try
|
||||
{
|
||||
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||
guildOwnerId = guild.OwnerId;
|
||||
guildName = guild.Name;
|
||||
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
||||
}
|
||||
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
|
||||
guildId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
|
||||
}
|
||||
|
||||
var timeResult = DiscordNewSessionHandler.ParseTimeInput(time);
|
||||
if (!timeResult.IsSuccess)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message($"X {timeResult.Error}"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer the response to avoid Discord 3-second interaction timeout
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Creating session for guild {GuildId}, user {UserId}", guildId, Context.User.Id);
|
||||
|
||||
var view = await _handler.HandleAsync(
|
||||
guildId: guildId.ToString(),
|
||||
channelId: Context.Channel!.Id.ToString(),
|
||||
groupName: guildName,
|
||||
userId: Context.User.Id,
|
||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
guildOwnerId: guildOwnerId,
|
||||
title: title,
|
||||
scheduledAt: timeResult.Value,
|
||||
maxPlayers: seats is null ? null : (int)seats.Value,
|
||||
joinLink: link,
|
||||
CancellationToken.None);
|
||||
|
||||
_logger.LogInformation("Session created successfully. Building render.");
|
||||
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(view);
|
||||
|
||||
_logger.LogInformation("Sending success response.");
|
||||
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = ":white_check_mark: **Session created successfully!**";
|
||||
message.Embeds = embeds;
|
||||
message.Components = actionRows;
|
||||
});
|
||||
|
||||
_logger.LogInformation("Success response sent.");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unauthorized session creation attempt by user {UserId}", Context.User.Id);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = $":no_entry: {ex.Message}";
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create session for user {UserId} in guild {GuildId}", Context.User.Id, guildId);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = ":boom: An error occurred while creating the session.";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
using System.Globalization;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, string? Error);
|
||||
|
||||
public sealed class DiscordNewSessionHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
ILogger<DiscordNewSessionHandler> logger)
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
public static TimeParseResult ParseTimeInput(string input)
|
||||
{
|
||||
var trimmed = input.Trim();
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"yyyy-MM-dd HH:mm",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt1))
|
||||
{
|
||||
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
if (DateTime.TryParseExact(
|
||||
trimmed,
|
||||
"dd.MM.yyyy HH:mm",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var dt2))
|
||||
{
|
||||
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
|
||||
if (offset < DateTimeOffset.UtcNow)
|
||||
return new TimeParseResult(false, default, "Дата находится в прошлом.");
|
||||
|
||||
return new TimeParseResult(true, offset, null);
|
||||
}
|
||||
|
||||
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
|
||||
}
|
||||
|
||||
public async Task<SessionBatchViewModel> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
string groupName,
|
||||
ulong userId,
|
||||
string userDisplayName,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
string title,
|
||||
DateTimeOffset scheduledAt,
|
||||
int? maxPlayers,
|
||||
string? joinLink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
|
||||
? title
|
||||
: groupName.Trim();
|
||||
|
||||
var dbManagerUserIds = await connection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord'
|
||||
AND p.platform = 'Discord'
|
||||
AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
{
|
||||
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут создавать сессии.");
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
var transactionCommitted = false;
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||
VALUES (@Name, 'Discord', @UserId, @Name)
|
||||
ON CONFLICT (platform, external_user_id)
|
||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||
DO UPDATE SET display_name = EXCLUDED.display_name,
|
||||
external_username = EXCLUDED.external_username",
|
||||
new { Name = userDisplayName, UserId = userId.ToString() },
|
||||
transaction);
|
||||
|
||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||
VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
|
||||
ON CONFLICT (platform, external_group_id)
|
||||
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
|
||||
DO UPDATE SET name = EXCLUDED.name,
|
||||
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
|
||||
RETURNING id",
|
||||
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@"INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT @GroupId, p.id, @OwnerRole
|
||||
FROM players p
|
||||
WHERE p.platform = 'Discord' AND p.external_user_id = @UserId
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING",
|
||||
new { GroupId = groupId, UserId = userId.ToString(), OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||
transaction);
|
||||
|
||||
var batchId = Guid.NewGuid();
|
||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
|
||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
|
||||
RETURNING id",
|
||||
new
|
||||
{
|
||||
BatchId = batchId,
|
||||
GroupId = groupId,
|
||||
Title = title,
|
||||
Link = joinLink ?? string.Empty,
|
||||
ScheduledAt = scheduledAt.UtcDateTime,
|
||||
Status = SessionStatus.Planned,
|
||||
MaxPlayers = maxPlayers
|
||||
},
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
transactionCommitted = true;
|
||||
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
|
||||
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!transactionCommitted)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
|
||||
public class DiscordRescheduleCommand : ApplicationCommandModule<SlashCommandContext>
|
||||
{
|
||||
private readonly DiscordRescheduleHandler _handler;
|
||||
private readonly ILogger<DiscordRescheduleCommand> _logger;
|
||||
|
||||
public DiscordRescheduleCommand(DiscordRescheduleHandler handler, ILogger<DiscordRescheduleCommand> logger)
|
||||
{
|
||||
_handler = handler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[SlashCommand("reschedule", "Initiate reschedule voting for a session")]
|
||||
public async Task ExecuteAsync(
|
||||
[SlashCommandParameter(Name = "session", Description = "Session ID to reschedule")] string sessionIdText,
|
||||
[SlashCommandParameter(Name = "option1", Description = "First time option (YYYY-MM-DD HH:mm)")] string option1,
|
||||
[SlashCommandParameter(Name = "option2", Description = "Second time option (YYYY-MM-DD HH:mm)")] string option2,
|
||||
[SlashCommandParameter(Name = "option3", Description = "Third time option (optional)")] string? option3 = null,
|
||||
[SlashCommandParameter(Name = "deadline", Description = "Voting deadline (YYYY-MM-DD HH:mm)")] string deadline = "")
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"reschedule called by user {UserId} ({UserType}) in guild {GuildId}",
|
||||
Context.User.Id,
|
||||
Context.User.GetType().Name,
|
||||
Context.Interaction.GuildId);
|
||||
|
||||
var guildId = Context.Interaction.GuildId
|
||||
?? throw new InvalidOperationException("This command can only be used in a guild.");
|
||||
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
if (member is null)
|
||||
{
|
||||
_logger.LogError("Context.User is not GuildInteractionUser. Actual type: {ActualType}", Context.User.GetType().Name);
|
||||
throw new InvalidOperationException("Guild member data not available in interaction.");
|
||||
}
|
||||
|
||||
var resolvedPermissions = (ulong)member.Permissions;
|
||||
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
|
||||
|
||||
ulong guildOwnerId = 0;
|
||||
try
|
||||
{
|
||||
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
|
||||
guildOwnerId = guild.OwnerId;
|
||||
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
|
||||
}
|
||||
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Bot is not a REST member of guild {GuildId}; using resolved permissions from interaction payload",
|
||||
guildId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error fetching guild {GuildId}", guildId);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(sessionIdText, out var sessionId))
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message("❌ Некорректный ID сессии."));
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new List<string> { option1, option2 };
|
||||
if (!string.IsNullOrWhiteSpace(option3))
|
||||
options.Add(option3);
|
||||
|
||||
var parsedOptions = new List<DateTimeOffset>();
|
||||
foreach (var opt in options)
|
||||
{
|
||||
var result = DiscordNewSessionHandler.ParseTimeInput(opt);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message($"❌ {opt}: {result.Error}"));
|
||||
return;
|
||||
}
|
||||
parsedOptions.Add(result.Value);
|
||||
}
|
||||
|
||||
var deadlineResult = DiscordNewSessionHandler.ParseTimeInput(deadline);
|
||||
if (!deadlineResult.IsSuccess)
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message($"❌ Дедлайн: {deadlineResult.Error}"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (deadlineResult.Value >= parsedOptions.Min())
|
||||
{
|
||||
await Context.Interaction.SendResponseAsync(
|
||||
InteractionCallback.Message("❌ Дедлайн должен быть раньше первого варианта времени."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer the response to avoid Discord 3-second interaction timeout
|
||||
await Context.Interaction.SendResponseAsync(InteractionCallback.DeferredMessage());
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Initiating reschedule for session {SessionId} in guild {GuildId}", sessionId, guildId);
|
||||
|
||||
var result = await _handler.HandleAsync(
|
||||
guildId: guildId.ToString(),
|
||||
channelId: Context.Channel!.Id.ToString(),
|
||||
userId: Context.User.Id,
|
||||
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
guildOwnerId: guildOwnerId,
|
||||
sessionId: sessionId,
|
||||
options: parsedOptions,
|
||||
deadline: deadlineResult.Value,
|
||||
CancellationToken.None);
|
||||
|
||||
_logger.LogInformation("Reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, result.ProposalId);
|
||||
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = $"🗳 Голосование за перенос запущено! Дедлайн: {deadlineResult.Value:yyyy-MM-dd HH:mm} UTC.";
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Unauthorized reschedule attempt by user {UserId}", Context.User.Id);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = $":no_entry: {ex.Message}";
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid reschedule request by user {UserId}", Context.User.Id);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = $":warning: {ex.Message}";
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to initiate reschedule for session {SessionId}", sessionId);
|
||||
await Context.Interaction.ModifyResponseAsync(message =>
|
||||
{
|
||||
message.Content = ":boom: Ошибка при запуске голосования.";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
using Dapper;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using Npgsql;
|
||||
|
||||
public sealed record DiscordRescheduleResult(Guid ProposalId, IReadOnlyList<RescheduleOptionDto> Options, DateTimeOffset Deadline);
|
||||
|
||||
public sealed class DiscordRescheduleHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
DiscordPermissionChecker permissionChecker,
|
||||
RestClient restClient,
|
||||
ILogger<DiscordRescheduleHandler> logger)
|
||||
{
|
||||
public async Task<DiscordRescheduleResult> HandleAsync(
|
||||
string guildId,
|
||||
string channelId,
|
||||
ulong userId,
|
||||
string userDisplayName,
|
||||
ulong resolvedPermissions,
|
||||
ulong guildOwnerId,
|
||||
Guid sessionId,
|
||||
IReadOnlyList<DateTimeOffset> options,
|
||||
DateTimeOffset deadline,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Permission check + read-only validation (before Discord message)
|
||||
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var dbManagerUserIds = await readConnection.QueryAsync<ulong>(
|
||||
@"SELECT CAST(p.external_user_id AS BIGINT)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
JOIN game_groups g ON g.id = gm.group_id
|
||||
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
|
||||
new { GuildId = guildId });
|
||||
|
||||
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
|
||||
{
|
||||
throw new UnauthorizedAccessException("⛔ Только owner, администратор или manager могут переносить сессии.");
|
||||
}
|
||||
|
||||
// 2. Ensure player exists
|
||||
await readConnection.ExecuteAsync(
|
||||
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||
VALUES (@Name, 'Discord', @UserId, @Name)
|
||||
ON CONFLICT (platform, external_user_id)
|
||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||
DO UPDATE SET display_name = EXCLUDED.display_name",
|
||||
new { Name = userDisplayName, UserId = userId.ToString() });
|
||||
|
||||
// 3. Verify session exists
|
||||
var session = await readConnection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title, s.scheduled_at AS CurrentScheduledAt
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
||||
""",
|
||||
new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled });
|
||||
|
||||
if (session is null)
|
||||
throw new InvalidOperationException("Сессия не найдена или отменена.");
|
||||
|
||||
// 4. Check no active proposal
|
||||
var hasActive = await readConnection.ExecuteScalarAsync<bool>(
|
||||
"SELECT EXISTS (SELECT 1 FROM reschedule_proposals WHERE session_id = @SessionId AND status IN ('AwaitingTime', 'Voting'))",
|
||||
new { SessionId = sessionId });
|
||||
|
||||
if (hasActive)
|
||||
throw new InvalidOperationException("Уже есть активный запрос на перенос этой сессии.");
|
||||
|
||||
// 5. Load participants for rendering
|
||||
var participants = (await readConnection.QueryAsync<VoteParticipantDto>(
|
||||
"""
|
||||
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
|
||||
""",
|
||||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
|
||||
// 6. Prepare proposal data
|
||||
var proposalId = Guid.NewGuid();
|
||||
var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList();
|
||||
|
||||
// 7. Build and send Discord vote message BEFORE transaction
|
||||
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []);
|
||||
|
||||
var channelIdUlong = ulong.Parse(channelId);
|
||||
|
||||
// NOTE: Discord message is sent before DB transaction to avoid orphaned proposals
|
||||
// if the send fails. There is a negligible race window where the message is visible
|
||||
// before the DB commit; in practice users cannot click faster than the transaction commits.
|
||||
var sentMessage = await restClient.SendMessageAsync(
|
||||
channelIdUlong,
|
||||
new MessageProperties()
|
||||
.WithEmbeds(new[] { embed })
|
||||
.WithComponents(new[] { actionRow }));
|
||||
|
||||
// 8. Create proposal + options + platform_messages in transaction
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_proposals (id, session_id, proposed_by, source_platform, proposed_by_external_user_id, status, voting_deadline_at)
|
||||
VALUES (@Id, @SessionId, NULL, 'Discord', @ProposedBy, 'Voting', @Deadline)
|
||||
""",
|
||||
new { Id = proposalId, SessionId = sessionId, ProposedBy = userId.ToString(), Deadline = deadline.UtcDateTime },
|
||||
transaction);
|
||||
|
||||
foreach (var option in optionDtos)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
|
||||
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
|
||||
""",
|
||||
new { option.OptionId, ProposalId = proposalId, option.ProposedAt, option.DisplayOrder },
|
||||
transaction);
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO platform_messages (platform, group_id, session_id, external_channel_id, external_message_id, purpose)
|
||||
VALUES ('Discord', (SELECT id FROM game_groups WHERE platform = 'Discord' AND external_group_id = @GuildId), @SessionId, @ChannelId, @MessageId, 'reschedule_vote')
|
||||
""",
|
||||
new { GuildId = guildId, SessionId = sessionId, ChannelId = channelId, MessageId = sentMessage.Id.ToString() },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Transaction failed after Discord message sent; deleting orphaned message");
|
||||
try { await restClient.DeleteMessageAsync(channelIdUlong, sentMessage.Id); } catch { /* best effort */ }
|
||||
throw;
|
||||
}
|
||||
|
||||
logger.LogInformation("Discord reschedule voting started for session {SessionId}, proposal {ProposalId}", sessionId, proposalId);
|
||||
|
||||
return new DiscordRescheduleResult(proposalId, optionDtos, deadline);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt);
|
||||
@@ -0,0 +1,63 @@
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using NetCord.Rest;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record DiscordRescheduleVoteInput(
|
||||
Guid OptionId,
|
||||
ulong UserId,
|
||||
string InteractionId,
|
||||
string GuildId,
|
||||
string ChannelId,
|
||||
string MessageId);
|
||||
|
||||
public sealed class DiscordRescheduleVoteHandler(
|
||||
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||
RestClient restClient,
|
||||
ILogger<DiscordRescheduleVoteHandler> logger)
|
||||
{
|
||||
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
|
||||
{
|
||||
var command = new HandleRescheduleVoteCommand(
|
||||
input.OptionId,
|
||||
new PlatformUser(PlatformKind.Discord, input.UserId.ToString(), string.Empty, null),
|
||||
new PlatformGroup(PlatformKind.Discord, input.GuildId, string.Empty, input.ChannelId),
|
||||
input.InteractionId,
|
||||
new PlatformMessageRef(PlatformKind.Discord, input.ChannelId, null, input.MessageId));
|
||||
|
||||
var result = await sharedHandler.HandleAsync(command, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.ReplyText!;
|
||||
}
|
||||
|
||||
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
|
||||
result.Title!,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt,
|
||||
result.Options,
|
||||
result.Participants,
|
||||
result.Votes);
|
||||
|
||||
var channelIdUlong = ulong.Parse(input.ChannelId);
|
||||
var messageIdUlong = ulong.Parse(input.MessageId);
|
||||
|
||||
try
|
||||
{
|
||||
await restClient.ModifyMessageAsync(channelIdUlong, messageIdUlong, options =>
|
||||
{
|
||||
options.Embeds = new[] { embed };
|
||||
options.Components = new[] { actionRow };
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId);
|
||||
}
|
||||
|
||||
return result.ReplyText!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
public sealed class DiscordRescheduleVotingDeadlineService(
|
||||
NpgsqlDataSource dataSource,
|
||||
RescheduleVotingFinalizer finalizer,
|
||||
IPlatformMessenger messenger,
|
||||
ILogger<DiscordRescheduleVotingDeadlineService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessDueProposals(stoppingToken);
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await ProcessDueProposals(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessDueProposals(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var proposalIds = await finalizer.GetDueProposalIdsAsync("Discord", ct);
|
||||
foreach (var id in proposalIds)
|
||||
{
|
||||
await TryFinalizeAsync(id, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to process Discord reschedule proposals");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryFinalizeAsync(Guid proposalId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await finalizer.FinalizeAsync(proposalId, ct);
|
||||
if (result is null)
|
||||
return;
|
||||
|
||||
if (result.SourcePlatform != "Discord")
|
||||
return;
|
||||
|
||||
await TryUpdateDiscordVoteMessage(result, ct);
|
||||
|
||||
if (result.SelectedOption is not null)
|
||||
{
|
||||
await TryUpdateBatchScheduleAsync(result, ct);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Finalized Discord reschedule proposal {ProposalId} for session {SessionId} with outcome {Outcome}",
|
||||
proposalId,
|
||||
result.SessionId,
|
||||
result.Decision.Outcome);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to finalize Discord proposal {ProposalId}", proposalId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryUpdateDiscordVoteMessage(RescheduleVotingFinalizerResult result, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var msgRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
|
||||
"""
|
||||
SELECT g.external_group_id AS ExternalGroupId,
|
||||
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
|
||||
pm.external_message_id AS ExternalMessageId
|
||||
FROM platform_messages pm
|
||||
JOIN game_groups g ON g.id = pm.group_id
|
||||
WHERE pm.session_id = @SessionId AND pm.purpose = 'reschedule_vote' AND pm.platform = 'Discord'
|
||||
ORDER BY pm.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { result.SessionId });
|
||||
|
||||
if (msgRef is null)
|
||||
return;
|
||||
|
||||
var group = CreateDiscordGroup(msgRef);
|
||||
|
||||
await messenger.UpdateRescheduleVoteAsync(
|
||||
new PlatformRescheduleVoteUpdate(
|
||||
group,
|
||||
new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
msgRef.ExternalGroupId,
|
||||
null,
|
||||
msgRef.ExternalMessageId),
|
||||
result.ProposalId,
|
||||
result.SessionId,
|
||||
result.Title,
|
||||
result.CurrentScheduledAt,
|
||||
result.VotingDeadlineAt,
|
||||
result.Decision,
|
||||
result.SelectedOption,
|
||||
result.Options,
|
||||
result.Votes,
|
||||
result.Participants),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update Discord vote message for session {SessionId}", result.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryUpdateBatchScheduleAsync(RescheduleVotingFinalizerResult result, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var batchRef = await connection.QuerySingleOrDefaultAsync<PlatformMessageRefDto>(
|
||||
"""
|
||||
SELECT g.external_group_id AS ExternalGroupId,
|
||||
COALESCE(pm.external_channel_id, g.external_channel_id, g.external_group_id) AS ExternalChannelId,
|
||||
pm.external_message_id AS ExternalMessageId
|
||||
FROM platform_messages pm
|
||||
JOIN game_groups g ON g.id = pm.group_id
|
||||
WHERE pm.batch_id = @BatchId AND pm.purpose = 'schedule' AND pm.platform = 'Discord'
|
||||
ORDER BY pm.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { result.BatchId });
|
||||
|
||||
if (batchRef is null)
|
||||
return;
|
||||
|
||||
var sessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { result.BatchId })).ToList();
|
||||
|
||||
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC
|
||||
""",
|
||||
new { result.BatchId })).ToList();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(result.Title, sessions, participants);
|
||||
var group = CreateDiscordGroup(batchRef);
|
||||
|
||||
await messenger.UpdateScheduleAsync(
|
||||
new PlatformScheduleMessage(
|
||||
group,
|
||||
view,
|
||||
new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
batchRef.ExternalGroupId,
|
||||
null,
|
||||
batchRef.ExternalMessageId)),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to update Discord batch schedule for session {SessionId}", result.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private static PlatformGroup CreateDiscordGroup(PlatformMessageRefDto message) =>
|
||||
new(
|
||||
PlatformKind.Discord,
|
||||
message.ExternalGroupId,
|
||||
message.ExternalGroupId,
|
||||
message.ExternalChannelId);
|
||||
|
||||
internal sealed record PlatformMessageRefDto(
|
||||
string ExternalGroupId,
|
||||
string ExternalChannelId,
|
||||
string ExternalMessageId);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed record DiscordSessionInteractionInput(
|
||||
Guid SessionId,
|
||||
string InteractionId,
|
||||
string GuildId,
|
||||
string ChannelId,
|
||||
string MessageId,
|
||||
ulong UserId,
|
||||
string Username,
|
||||
string? DisplayName);
|
||||
|
||||
public static class DiscordSessionInteractionMapper
|
||||
{
|
||||
public static bool TryParseCustomId(string customId, string expectedAction, out Guid sessionId)
|
||||
{
|
||||
sessionId = default;
|
||||
|
||||
var parts = customId.Split(':', 2);
|
||||
return parts.Length == 2
|
||||
&& string.Equals(parts[0], expectedAction, StringComparison.Ordinal)
|
||||
&& Guid.TryParse(parts[1], out sessionId);
|
||||
}
|
||||
|
||||
public static JoinSessionCommand CreateJoinCommand(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
SessionId: input.SessionId,
|
||||
User: CreateUser(input),
|
||||
InteractionId: input.InteractionId,
|
||||
Group: CreateGroup(input),
|
||||
ScheduleMessage: CreateMessageRef(input));
|
||||
|
||||
public static LeaveSessionCommand CreateLeaveCommand(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
SessionId: input.SessionId,
|
||||
User: CreateUser(input),
|
||||
InteractionId: input.InteractionId,
|
||||
Group: CreateGroup(input),
|
||||
ScheduleMessage: CreateMessageRef(input));
|
||||
|
||||
private static PlatformUser CreateUser(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
PlatformKind.Discord,
|
||||
input.UserId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
string.IsNullOrWhiteSpace(input.DisplayName) ? input.Username : input.DisplayName,
|
||||
input.Username);
|
||||
|
||||
private static PlatformGroup CreateGroup(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
PlatformKind.Discord,
|
||||
input.GuildId,
|
||||
input.GuildId,
|
||||
input.ChannelId);
|
||||
|
||||
private static PlatformMessageRef CreateMessageRef(DiscordSessionInteractionInput input) =>
|
||||
new(
|
||||
PlatformKind.Discord,
|
||||
input.GuildId,
|
||||
null,
|
||||
input.MessageId);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
|
||||
namespace GmRelay.DiscordBot.Features.Sessions;
|
||||
|
||||
public sealed class DiscordSessionInteractionModule(
|
||||
JoinSessionHandler joinSessionHandler,
|
||||
LeaveSessionHandler leaveSessionHandler,
|
||||
HandleRsvpHandler rsvpHandler,
|
||||
DiscordDeleteSessionHandler deleteSessionHandler,
|
||||
DiscordRescheduleVoteHandler voteHandler,
|
||||
DiscordInteractionReplyCache interactionReplies,
|
||||
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
|
||||
{
|
||||
[ComponentInteraction("join_session")]
|
||||
public async Task JoinAsync(string sessionId)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
SessionInteractionResult result;
|
||||
try
|
||||
{
|
||||
result = await joinSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
|
||||
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||
}
|
||||
|
||||
[ComponentInteraction("leave_session")]
|
||||
public async Task LeaveAsync(string sessionId)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
SessionInteractionResult result;
|
||||
try
|
||||
{
|
||||
result = await leaveSessionHandler.HandleAsync(
|
||||
DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
|
||||
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
|
||||
}
|
||||
|
||||
[ComponentInteraction("delete_session")]
|
||||
public async Task DeleteAsync(string sessionId)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
var member = Context.User as GuildInteractionUser;
|
||||
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
|
||||
|
||||
await RespondAsync(InteractionCallback.DeferredModifyMessage);
|
||||
try
|
||||
{
|
||||
var result = await deleteSessionHandler.HandleAsync(
|
||||
guildId: input.GuildId,
|
||||
channelId: input.ChannelId,
|
||||
userId: input.UserId,
|
||||
resolvedPermissions: resolvedPermissions,
|
||||
guildOwnerId: 0,
|
||||
sessionId: parsedSessionId,
|
||||
CancellationToken.None);
|
||||
|
||||
await CompleteDeleteResponseAsync(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
|
||||
await FollowupEphemeralAsync("Не удалось удалить сессию.");
|
||||
}
|
||||
}
|
||||
|
||||
[ComponentInteraction("rsvp")]
|
||||
public async Task RsvpAsync(string status, string sessionId)
|
||||
{
|
||||
if (!Guid.TryParse(sessionId, out var parsedSessionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var rsvpStatus = status switch
|
||||
{
|
||||
"confirm" => RsvpStatus.Confirmed,
|
||||
"decline" => RsvpStatus.Declined,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (rsvpStatus is null)
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(parsedSessionId);
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
|
||||
try
|
||||
{
|
||||
await rsvpHandler.HandleAsync(
|
||||
new HandleRsvpCommand(
|
||||
parsedSessionId,
|
||||
new PlatformUser(
|
||||
PlatformKind.Discord,
|
||||
Context.User.Id.ToString(CultureInfo.InvariantCulture),
|
||||
string.IsNullOrWhiteSpace(Context.User.GlobalName) ? Context.User.Username : Context.User.GlobalName,
|
||||
Context.User.Username),
|
||||
rsvpStatus,
|
||||
input.InteractionId,
|
||||
new PlatformGroup(
|
||||
PlatformKind.Discord,
|
||||
input.GuildId,
|
||||
input.GuildId,
|
||||
input.ChannelId),
|
||||
new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
input.GuildId,
|
||||
null,
|
||||
input.MessageId)),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
|
||||
await CompleteResponseAsync("Не удалось обработать кнопку.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteWithStoredReplyAsync(input.InteractionId);
|
||||
}
|
||||
|
||||
[ComponentInteraction("reschedule_vote")]
|
||||
public async Task RescheduleVoteAsync(string optionId)
|
||||
{
|
||||
if (!Guid.TryParse(optionId, out var parsedOptionId))
|
||||
{
|
||||
await RespondAsync(CreateEphemeralReply("Vote button is outdated."));
|
||||
return;
|
||||
}
|
||||
|
||||
var input = CreateInput(Guid.Empty); // sessionId not needed for vote routing
|
||||
var voteInput = new DiscordRescheduleVoteInput(
|
||||
parsedOptionId,
|
||||
Context.User.Id,
|
||||
Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
input.GuildId,
|
||||
input.ChannelId,
|
||||
input.MessageId);
|
||||
|
||||
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
|
||||
|
||||
string replyText;
|
||||
try
|
||||
{
|
||||
replyText = await voteHandler.HandleAsync(voteInput, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to handle Discord reschedule vote for option {OptionId}", parsedOptionId);
|
||||
await CompleteResponseAsync("Не удалось обработать голос.");
|
||||
return;
|
||||
}
|
||||
|
||||
await CompleteResponseAsync(replyText);
|
||||
}
|
||||
|
||||
private DiscordSessionInteractionInput CreateInput(Guid sessionId)
|
||||
{
|
||||
var guildId = Context.Interaction.GuildId?.ToString(CultureInfo.InvariantCulture)
|
||||
?? throw new InvalidOperationException("Session buttons can only be used in a guild.");
|
||||
var message = Context.Interaction.Message
|
||||
?? throw new InvalidOperationException("Session button interaction must include a message.");
|
||||
|
||||
return new DiscordSessionInteractionInput(
|
||||
SessionId: sessionId,
|
||||
InteractionId: Context.Interaction.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
GuildId: guildId,
|
||||
ChannelId: Context.Channel.Id.ToString(CultureInfo.InvariantCulture),
|
||||
MessageId: message.Id.ToString(CultureInfo.InvariantCulture),
|
||||
UserId: Context.User.Id,
|
||||
Username: Context.User.Username,
|
||||
DisplayName: Context.User.GlobalName);
|
||||
}
|
||||
|
||||
private async Task CompleteWithStoredReplyAsync(string interactionId)
|
||||
{
|
||||
var reply = interactionReplies.Take(interactionId);
|
||||
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
|
||||
}
|
||||
|
||||
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
|
||||
{
|
||||
var updatedView = result.UpdatedView;
|
||||
if (updatedView is not null && SourceMessageHasDeleteAction())
|
||||
{
|
||||
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
|
||||
}
|
||||
|
||||
if (updatedView is not null)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
|
||||
var reply = interactionReplies.Take(interactionId);
|
||||
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
|
||||
}
|
||||
|
||||
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
|
||||
{
|
||||
if (result.UpdatedView is not null)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
else if (result.EmptyMessage is not null)
|
||||
{
|
||||
await ModifyResponseAsync(options =>
|
||||
{
|
||||
options.Content = result.EmptyMessage;
|
||||
options.Embeds = [];
|
||||
options.Components = [];
|
||||
});
|
||||
}
|
||||
|
||||
await FollowupEphemeralAsync(result.ReplyText);
|
||||
}
|
||||
|
||||
private Task CompleteResponseAsync(string text) =>
|
||||
ModifyResponseAsync(options => options.Content = text);
|
||||
|
||||
private Task FollowupEphemeralAsync(string text) =>
|
||||
FollowupAsync(new InteractionMessageProperties()
|
||||
.WithContent(text)
|
||||
.WithFlags(MessageFlags.Ephemeral));
|
||||
|
||||
private bool SourceMessageHasDeleteAction() =>
|
||||
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
|
||||
|
||||
private static bool ComponentContainsDeleteAction(object? component)
|
||||
{
|
||||
if (component is null)
|
||||
return false;
|
||||
|
||||
if (component is IInteractiveComponent interactive
|
||||
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
|
||||
if (nestedComponents is null)
|
||||
return false;
|
||||
|
||||
foreach (var nestedComponent in nestedComponents)
|
||||
{
|
||||
if (ComponentContainsDeleteAction(nestedComponent))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
|
||||
InteractionCallback.Message(
|
||||
new InteractionMessageProperties()
|
||||
.WithContent(text)
|
||||
.WithFlags(MessageFlags.Ephemeral));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
||||
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
|
||||
<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="NetCord.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,17 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
|
||||
public sealed class DiscordInteractionReplyCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PlatformInteractionReply> replies = new(StringComparer.Ordinal);
|
||||
|
||||
public void Store(PlatformInteractionReply reply) =>
|
||||
replies[reply.InteractionId] = reply;
|
||||
|
||||
public PlatformInteractionReply? Take(string interactionId) =>
|
||||
replies.TryRemove(interactionId, out var reply)
|
||||
? reply
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
|
||||
public sealed class DiscordPermissionChecker
|
||||
{
|
||||
private const ulong AdministratorPermission = 0x8;
|
||||
|
||||
public bool CanManageSchedule(
|
||||
ulong guildOwnerId,
|
||||
ulong userId,
|
||||
IEnumerable<ulong> dbManagerUserIds,
|
||||
ulong resolvedPermissions)
|
||||
{
|
||||
if (userId == guildOwnerId)
|
||||
return true;
|
||||
|
||||
if (dbManagerUserIds.Contains(userId))
|
||||
return true;
|
||||
|
||||
return (resolvedPermissions & AdministratorPermission) == AdministratorPermission;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using GmRelay.DiscordBot.Rendering;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
|
||||
public sealed class DiscordPlatformMessenger : IPlatformMessenger
|
||||
{
|
||||
private readonly RestClient restClient;
|
||||
private readonly DiscordInteractionReplyCache interactionReplies;
|
||||
private readonly ILogger<DiscordPlatformMessenger>? logger;
|
||||
|
||||
public DiscordPlatformMessenger(
|
||||
RestClient restClient,
|
||||
DiscordInteractionReplyCache interactionReplies)
|
||||
: this(restClient, interactionReplies, logger: null)
|
||||
{
|
||||
}
|
||||
|
||||
public DiscordPlatformMessenger(
|
||||
RestClient restClient,
|
||||
DiscordInteractionReplyCache interactionReplies,
|
||||
ILogger<DiscordPlatformMessenger>? logger)
|
||||
{
|
||||
this.restClient = restClient;
|
||||
this.interactionReplies = interactionReplies;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||
{
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
|
||||
|
||||
var channelId = GetChannelId(message.Group);
|
||||
|
||||
var msg = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties()
|
||||
.WithEmbeds(embeds)
|
||||
.WithComponents(actionRows));
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
message.Group.ExternalGroupId,
|
||||
null,
|
||||
msg.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public async Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||
{
|
||||
if (message.ExistingMessage is null)
|
||||
return;
|
||||
|
||||
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(message.View);
|
||||
|
||||
var channelId = GetChannelId(message.Group);
|
||||
var messageId = ParseSnowflake(message.ExistingMessage.ExternalMessageId);
|
||||
|
||||
await restClient.ModifyMessageAsync(
|
||||
channelId,
|
||||
messageId,
|
||||
options =>
|
||||
{
|
||||
options.Embeds = embeds;
|
||||
options.Components = actionRows;
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct)
|
||||
{
|
||||
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
|
||||
}
|
||||
|
||||
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
var rows = BuildActionRows(actions);
|
||||
await restClient.SendMessageAsync(GetChannelId(group), new MessageProperties().WithContent(htmlText).WithComponents(rows));
|
||||
}
|
||||
|
||||
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
|
||||
var messageId = ParseSnowflake(messageRef.ExternalMessageId);
|
||||
var rows = BuildActionRows(actions);
|
||||
await restClient.ModifyMessageAsync(channelId, messageId, options =>
|
||||
{
|
||||
options.Content = htmlText;
|
||||
options.Components = rows;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||
{
|
||||
// Discord thread creation is not implemented in this adapter
|
||||
return Task.FromResult(new PlatformMessageRef(PlatformKind.Discord, group.ExternalGroupId, group.ExternalThreadId, string.Empty));
|
||||
}
|
||||
|
||||
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public async Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
|
||||
await restClient.DeleteMessageAsync(channelId, ParseSnowflake(messageRef.ExternalMessageId));
|
||||
}
|
||||
|
||||
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
|
||||
{
|
||||
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
|
||||
}
|
||||
|
||||
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct)
|
||||
{
|
||||
interactionReplies.Store(reply);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendConfirmationRequestAsync(
|
||||
PlatformConfirmationRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(request.Group);
|
||||
try
|
||||
{
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties()
|
||||
.WithEmbeds([BuildConfirmationEmbed(request)])
|
||||
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
|
||||
|
||||
logger?.LogInformation(
|
||||
"Confirmation request sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||
channelId,
|
||||
message.Id);
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
request.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to send confirmation request to Discord channel {ChannelId} for session {SessionId}",
|
||||
channelId,
|
||||
request.SessionId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
|
||||
{
|
||||
if (update.Request.ExistingMessage is null)
|
||||
return;
|
||||
|
||||
var channelId = GetChannelId(update.Request.Group);
|
||||
var messageId = ParseSnowflake(update.Request.ExistingMessage.ExternalMessageId);
|
||||
var components = BuildRsvpRows(update.Request.SessionId, update.DisableActions);
|
||||
|
||||
await restClient.ModifyMessageAsync(
|
||||
channelId,
|
||||
messageId,
|
||||
options =>
|
||||
{
|
||||
options.Embeds = [BuildConfirmationEmbed(update.Request)];
|
||||
options.Components = components;
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<PlatformMessageRef> SendJoinLinkNotificationAsync(
|
||||
PlatformJoinLinkNotification notification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var channelId = GetChannelId(notification.Group);
|
||||
try
|
||||
{
|
||||
var message = await restClient.SendMessageAsync(
|
||||
channelId,
|
||||
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
|
||||
|
||||
logger?.LogInformation(
|
||||
"Join link sent to Discord channel {ChannelId}, message id {MessageId}",
|
||||
channelId,
|
||||
message.Id);
|
||||
|
||||
return new PlatformMessageRef(
|
||||
PlatformKind.Discord,
|
||||
notification.Group.ExternalGroupId,
|
||||
null,
|
||||
message.Id.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(
|
||||
ex,
|
||||
"Failed to send join link to Discord channel {ChannelId} for session {SessionId}",
|
||||
channelId,
|
||||
notification.SessionId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendDirectSessionNotificationAsync(
|
||||
PlatformDirectSessionNotification notification,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendDirectContentAsync(
|
||||
notification.Recipient,
|
||||
BuildDirectContent(notification),
|
||||
ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
ex,
|
||||
"Failed to send Discord direct notification {NotificationKind} for session {SessionId} to user {ExternalUserId}",
|
||||
notification.Kind,
|
||||
notification.SessionId,
|
||||
notification.Recipient.ExternalUserId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendRsvpOutcomeAsync(PlatformRsvpOutcomeNotification notification, CancellationToken ct)
|
||||
{
|
||||
if (notification.Kind == PlatformRsvpOutcomeKind.GroupAllConfirmed && notification.Group is not null)
|
||||
{
|
||||
await restClient.SendMessageAsync(
|
||||
GetChannelId(notification.Group),
|
||||
BuildRsvpGroupOutcomeContent(notification));
|
||||
return;
|
||||
}
|
||||
|
||||
var directKind = notification.Kind == PlatformRsvpOutcomeKind.GmPlayerDeclined
|
||||
? PlatformDirectSessionNotificationKind.RsvpDeclined
|
||||
: PlatformDirectSessionNotificationKind.RsvpAllConfirmed;
|
||||
|
||||
foreach (var recipient in notification.Recipients)
|
||||
{
|
||||
await SendDirectSessionNotificationAsync(
|
||||
new PlatformDirectSessionNotification(
|
||||
directKind,
|
||||
recipient,
|
||||
notification.SessionId,
|
||||
notification.Title,
|
||||
notification.ScheduledAt,
|
||||
ActorDisplayName: notification.ActorDisplayName),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateRescheduleVoteAsync(PlatformRescheduleVoteUpdate update, CancellationToken ct)
|
||||
{
|
||||
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
|
||||
update.Title,
|
||||
update.CurrentScheduledAt,
|
||||
update.VotingDeadlineAt,
|
||||
update.Options,
|
||||
update.Participants,
|
||||
update.Votes);
|
||||
|
||||
var disabledRow = new ActionRowProperties();
|
||||
foreach (var button in actionRow.OfType<ButtonProperties>())
|
||||
{
|
||||
disabledRow.Add(new ButtonProperties(
|
||||
button.CustomId,
|
||||
button.Label ?? string.Empty,
|
||||
ButtonStyle.Secondary)
|
||||
{
|
||||
Disabled = true
|
||||
});
|
||||
}
|
||||
|
||||
var updatedEmbed = embed.WithDescription(
|
||||
$"{embed.Description}\n\n{BuildRescheduleResultText(update)}");
|
||||
|
||||
await restClient.ModifyMessageAsync(
|
||||
GetChannelId(update.Group),
|
||||
ParseSnowflake(update.ExistingMessage.ExternalMessageId),
|
||||
options =>
|
||||
{
|
||||
options.Embeds = [updatedEmbed];
|
||||
options.Components = [disabledRow];
|
||||
});
|
||||
}
|
||||
|
||||
private static EmbedProperties BuildConfirmationEmbed(PlatformConfirmationRequest request)
|
||||
{
|
||||
var embed = new EmbedProperties()
|
||||
.WithTitle($"Подтверждение: {request.Title}")
|
||||
.WithDescription(BuildConfirmationDescription(request))
|
||||
.WithColor(new Color(0x5865F2));
|
||||
|
||||
return embed.AddFields(
|
||||
[
|
||||
BuildParticipantField("Подтвердили", request.Participants, RsvpStatus.Confirmed),
|
||||
BuildParticipantField("Отказались", request.Participants, RsvpStatus.Declined),
|
||||
BuildParticipantField("Ожидаем ответ", request.Participants, RsvpStatus.Pending)
|
||||
]);
|
||||
}
|
||||
|
||||
private static string BuildConfirmationDescription(PlatformConfirmationRequest request) =>
|
||||
$"Время: **{request.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
||||
"Подтвердите участие кнопкой ниже.";
|
||||
|
||||
private static EmbedFieldProperties BuildParticipantField(
|
||||
string title,
|
||||
IReadOnlyList<PlatformSessionParticipant> participants,
|
||||
string status)
|
||||
{
|
||||
var values = participants
|
||||
.Where(participant => participant.RsvpStatus == status)
|
||||
.Select(FormatDiscordParticipant)
|
||||
.ToList();
|
||||
|
||||
return new EmbedFieldProperties()
|
||||
.WithName(title)
|
||||
.WithValue(values.Count == 0 ? "—" : string.Join("\n", values))
|
||||
.WithInline();
|
||||
}
|
||||
|
||||
private static EmbedProperties BuildJoinLinkEmbed(PlatformJoinLinkNotification notification)
|
||||
{
|
||||
var mentions = notification.ConfirmedPlayers.Count == 0
|
||||
? "—"
|
||||
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
|
||||
|
||||
var embed = new EmbedProperties()
|
||||
.WithTitle($"Ссылка на игру: {notification.Title}")
|
||||
.WithDescription(
|
||||
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
|
||||
$"Ссылка: {notification.JoinLink}\n\n" +
|
||||
$"Участники: {mentions}")
|
||||
.WithColor(new Color(0x57F287));
|
||||
|
||||
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(notification.JoinLink);
|
||||
return embedUrl is null ? embed : embed.WithUrl(embedUrl);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
row.Add(new ButtonProperties($"rsvp:confirm:{sessionId}", "Буду", ButtonStyle.Success)
|
||||
{
|
||||
Disabled = disabled
|
||||
});
|
||||
row.Add(new ButtonProperties($"rsvp:decline:{sessionId}", "Не смогу", ButtonStyle.Danger)
|
||||
{
|
||||
Disabled = disabled
|
||||
});
|
||||
|
||||
return [row];
|
||||
}
|
||||
|
||||
private static string BuildDirectContent(PlatformDirectSessionNotification notification)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(notification.Kind switch
|
||||
{
|
||||
PlatformDirectSessionNotificationKind.ConfirmationRequest => "Нужно подтвердить участие",
|
||||
PlatformDirectSessionNotificationKind.OneHourReminder => "Напоминание: сессия через час",
|
||||
PlatformDirectSessionNotificationKind.JoinLink => "Ссылка на игру",
|
||||
PlatformDirectSessionNotificationKind.RsvpAllConfirmed => "Все игроки подтвердили участие",
|
||||
PlatformDirectSessionNotificationKind.RsvpDeclined => "Игрок отказался от участия",
|
||||
PlatformDirectSessionNotificationKind.RescheduleApproved => "Сессия перенесена",
|
||||
PlatformDirectSessionNotificationKind.RescheduleRejected => "Перенос сессии отклонен",
|
||||
_ => "Уведомление по сессии"
|
||||
});
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"**{notification.Title}**");
|
||||
builder.AppendLine($"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.JoinLink))
|
||||
builder.AppendLine($"Ссылка: {notification.JoinLink}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.ActorDisplayName))
|
||||
builder.AppendLine($"Игрок: {notification.ActorDisplayName}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.Reason))
|
||||
builder.AppendLine($"Причина: {notification.Reason}");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildRsvpGroupOutcomeContent(PlatformRsvpOutcomeNotification notification) =>
|
||||
$"Все участники подтвердили сессию **{notification.Title}** на " +
|
||||
$"**{notification.ScheduledAt.FormatMoscow()}** (МСК).";
|
||||
|
||||
private static string BuildRescheduleResultText(PlatformRescheduleVoteUpdate update)
|
||||
{
|
||||
if (update.SelectedOption is not null)
|
||||
{
|
||||
return "Голосование завершено. " +
|
||||
$"Победил вариант {update.SelectedOption.DisplayOrder}: " +
|
||||
$"**{update.SelectedOption.ProposedAt.FormatMoscow()}** (МСК).";
|
||||
}
|
||||
|
||||
return $"Голосование завершено. {update.Decision.Reason}";
|
||||
}
|
||||
|
||||
private async Task SendDirectContentAsync(PlatformUser recipient, string content, CancellationToken ct)
|
||||
{
|
||||
var userId = ParseSnowflake(recipient.ExternalUserId);
|
||||
var dm = await restClient.GetDMChannelAsync(userId, cancellationToken: ct);
|
||||
await restClient.SendMessageAsync(
|
||||
dm.Id,
|
||||
new MessageProperties().WithContent(content),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
private static string FormatDiscordParticipant(PlatformSessionParticipant participant) =>
|
||||
$"{Mention(participant.User)} ({participant.User.DisplayName})";
|
||||
|
||||
private static string Mention(PlatformUser user) => $"<@{user.ExternalUserId}>";
|
||||
|
||||
private static ulong GetChannelId(PlatformGroup group)
|
||||
{
|
||||
var channelId = group.ExternalChannelId ?? group.ExternalGroupId
|
||||
?? throw new InvalidOperationException("Discord group has no channel or group identifier.");
|
||||
|
||||
return ParseSnowflake(channelId);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ActionRowProperties> BuildActionRows(IReadOnlyList<PlatformMessageAction> actions)
|
||||
{
|
||||
if (actions.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var rows = new List<ActionRowProperties>();
|
||||
foreach (var chunk in actions.Chunk(5))
|
||||
{
|
||||
var row = new ActionRowProperties();
|
||||
foreach (var action in chunk)
|
||||
{
|
||||
row.Add(new ButtonProperties(action.Key, action.Label, ButtonStyle.Secondary)
|
||||
{
|
||||
CustomId = action.Payload
|
||||
});
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static ulong ParseSnowflake(string value) =>
|
||||
ulong.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net;
|
||||
|
||||
namespace GmRelay.DiscordBot.Infrastructure.Health;
|
||||
|
||||
public sealed class DiscordHealthCheckHostedService : IHostedService
|
||||
{
|
||||
private readonly ILogger<DiscordHealthCheckHostedService> _logger;
|
||||
private readonly string _prefix;
|
||||
private HttpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _listenerTask;
|
||||
|
||||
public DiscordHealthCheckHostedService(
|
||||
ILogger<DiscordHealthCheckHostedService> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8082/")!;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add(_prefix);
|
||||
_listener.Start();
|
||||
|
||||
_logger.LogInformation("Discord health check server started on {Prefix}", _prefix);
|
||||
|
||||
_listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_listener?.Stop();
|
||||
|
||||
if (_listenerTask != null)
|
||||
{
|
||||
await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
|
||||
}
|
||||
|
||||
_listener?.Close();
|
||||
_logger.LogInformation("Discord health check server stopped");
|
||||
}
|
||||
|
||||
private async Task ListenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _listener.GetContextAsync();
|
||||
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
|
||||
}
|
||||
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in Discord health check listener");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
var response = context.Response;
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
|
||||
if (request.Url?.AbsolutePath == "/health")
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.OK;
|
||||
response.ContentType = "application/json";
|
||||
var body = "{\"status\":\"healthy\"}"u8.ToArray();
|
||||
await response.OutputStream.WriteAsync(body);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.NotFound;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling Discord health check request");
|
||||
}
|
||||
finally
|
||||
{
|
||||
response.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,6 @@
|
||||
namespace GmRelay.DiscordBot.Infrastructure;
|
||||
|
||||
public sealed class SystemClock : GmRelay.Shared.Platform.ISystemClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using GmRelay.DiscordBot;
|
||||
using GmRelay.DiscordBot.Features.Sessions;
|
||||
using GmRelay.DiscordBot.Infrastructure;
|
||||
using GmRelay.DiscordBot.Infrastructure.Discord;
|
||||
using GmRelay.DiscordBot.Infrastructure.Health;
|
||||
using GmRelay.DiscordBot.Infrastructure.Logging;
|
||||
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
|
||||
using GmRelay.Shared.Features.Notifications;
|
||||
using GmRelay.Shared.Features.Reminders.SendJoinLink;
|
||||
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NetCord;
|
||||
using NetCord.Gateway;
|
||||
using NetCord.Hosting.Gateway;
|
||||
using NetCord.Hosting.Services;
|
||||
using NetCord.Hosting.Services.ApplicationCommands;
|
||||
using NetCord.Hosting.Services.ComponentInteractions;
|
||||
using NetCord.Services.ApplicationCommands;
|
||||
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.Logging.AddConsole();
|
||||
|
||||
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.AddSingleton<DiscordPermissionChecker>();
|
||||
builder.Services.AddSingleton<DiscordListSessionsHandler>();
|
||||
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordNewSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleHandler>();
|
||||
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
|
||||
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||
builder.Services.AddSingleton<DiscordInteractionReplyCache>();
|
||||
builder.Services.AddSingleton<IPlatformMessenger, DiscordPlatformMessenger>();
|
||||
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Discord));
|
||||
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
|
||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
|
||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
|
||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
|
||||
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
|
||||
|
||||
builder.Services
|
||||
.AddDiscordGateway(options =>
|
||||
{
|
||||
options.Token = discordOptions.Token;
|
||||
options.Intents = GatewayIntents.Guilds;
|
||||
})
|
||||
.AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
|
||||
.AddComponentInteractions<ButtonInteraction, ButtonInteractionContext>()
|
||||
.AddGatewayHandlers(typeof(Program).Assembly);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
host.AddSlashCommand("ping", "Checks whether GM-Relay Discord is online.", () => "Pong!");
|
||||
host.AddModules(typeof(Program).Assembly);
|
||||
|
||||
await host.RunAsync();
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace GmRelay.DiscordBot.Rendering;
|
||||
|
||||
public static class DiscordEmbedUrls
|
||||
{
|
||||
public static string? NormalizeHttpUrl(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var candidate = value.Trim();
|
||||
if (IsSupportedHttpUrl(candidate, out var normalized))
|
||||
return normalized;
|
||||
|
||||
if (candidate.Contains("://", StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
return IsSupportedHttpUrl($"https://{candidate}", out normalized)
|
||||
&& HasPublicHost(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsSupportedHttpUrl(string value, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = uri.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasPublicHost(string value) =>
|
||||
Uri.TryCreate(value, UriKind.Absolute, out var uri)
|
||||
&& uri.Host.Contains('.', StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace GmRelay.DiscordBot.Rendering;
|
||||
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
using NetCord;
|
||||
using NetCord.Rest;
|
||||
|
||||
public static class DiscordRescheduleVotingRenderer
|
||||
{
|
||||
public static (EmbedProperties Embed, ActionRowProperties ActionRow) Render(
|
||||
string title,
|
||||
DateTime currentTime,
|
||||
DateTimeOffset deadline,
|
||||
IReadOnlyList<RescheduleOptionDto> options,
|
||||
IReadOnlyList<VoteParticipantDto> participants,
|
||||
IReadOnlyList<RescheduleOptionVoteDto> votes)
|
||||
{
|
||||
var votesByOption = votes.GroupBy(v => v.OptionId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
|
||||
var pending = participants.Where(p => !votedPlayerIds.Contains(p.PlayerId)).Select(p => p.DisplayName).ToList();
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)");
|
||||
sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Выберите один из вариантов:");
|
||||
|
||||
foreach (var option in options.OrderBy(o => o.DisplayOrder))
|
||||
{
|
||||
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
|
||||
sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {optionVotes.Count} голосов");
|
||||
if (optionVotes.Count > 0)
|
||||
{
|
||||
sb.AppendLine($" {string.Join(", ", optionVotes.Select(v => v.DisplayName))}");
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Не проголосовали: {string.Join(", ", pending)}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
|
||||
sb.AppendLine("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
|
||||
|
||||
var embed = new EmbedProperties()
|
||||
.WithTitle($"🔄 Перенос сессии «{title}»")
|
||||
.WithDescription(sb.ToString())
|
||||
.WithColor(new Color(0xFEE75C));
|
||||
|
||||
var actionRow = new ActionRowProperties();
|
||||
foreach (var option in options.OrderBy(o => o.DisplayOrder))
|
||||
{
|
||||
actionRow.Add(new ButtonProperties(
|
||||
$"reschedule_vote:{option.OptionId}",
|
||||
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
|
||||
ButtonStyle.Primary));
|
||||
}
|
||||
|
||||
return (embed, actionRow);
|
||||
}
|
||||
|
||||
private static string FormatButtonTime(DateTimeOffset utc)
|
||||
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString("dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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()
|
||||
};
|
||||
|
||||
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(session.JoinLink);
|
||||
if (embedUrl is not null)
|
||||
{
|
||||
embed = embed.WithUrl(embedUrl);
|
||||
}
|
||||
|
||||
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,685 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"Dapper": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.1.72, )",
|
||||
"resolved": "2.1.72",
|
||||
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
|
||||
},
|
||||
"Dapper.AOT": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.0.48, )",
|
||||
"resolved": "1.0.48",
|
||||
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"NetCord.Services": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.0.0-alpha.489, )",
|
||||
"resolved": "1.0.0-alpha.489",
|
||||
"contentHash": "SwG/7Khba1uRENDvG22RV/POByIwh/ZrenMrSzwoEcEYPMI5TabmEEB3ySH15XGdLcFZJEj106AlriN0kZhfFg==",
|
||||
"dependencies": {
|
||||
"NetCord": "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=="
|
||||
},
|
||||
"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",
|
||||
"dependencies": {
|
||||
"Dapper": "[2.1.72, )",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )",
|
||||
"Npgsql": "[10.0.2, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user