Compare commits
360 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27f9ceb038 | |||
| f53c1f6aae | |||
| e59b0a78fd | |||
| b952be23eb | |||
| 4054d49ccb | |||
| d678c59105 | |||
| 20b4240a11 | |||
| e846a75ca1 | |||
| 29e5652477 | |||
| 02fc5bd106 | |||
| 6cd68493f1 | |||
| de121d7523 | |||
| 3c967dc3e3 | |||
| 7d5dd2ed0a | |||
| 7cb5b03cc2 | |||
| 014b5edd31 | |||
| bbd58142db | |||
| 956ec01583 | |||
| 5014ca5c58 | |||
| efd86bca0a | |||
| 2241568bac | |||
| 37ed697696 | |||
| 320ec18ab0 | |||
| 4424d8faad | |||
| 1f3fb6e89e | |||
| e3e6e841b8 | |||
| a0a84965b3 | |||
| 67e8d5b558 | |||
| 593f8a62fb | |||
| aee0ac1e6c | |||
| 68945d931f | |||
| 3db2b703d6 | |||
| 3c3ef8db5a | |||
| 5c0397a5e6 | |||
| 15040eb954 | |||
| 99a58d7835 | |||
| f491727cec | |||
| 2c9016a383 | |||
| 065e8011ee | |||
| f796b7d1e4 | |||
| 415c13bf00 | |||
| 85ff3a7faf | |||
| d034d6acb9 | |||
| c4a77d3d73 | |||
| 7cfb1968c0 | |||
| b1bd47f6c1 | |||
| f0952096f3 | |||
| b81d865832 | |||
| 8f0f2ef7e7 | |||
| 71080aeab6 | |||
| a843c8b278 | |||
| 186492a18d | |||
| 2819786f91 | |||
| 8c1bda73ed | |||
| af345ba765 | |||
| 4a04d7d723 | |||
| eeffae659f | |||
| ea567a36ee | |||
| be86a2a08a | |||
| 1b49211085 | |||
| 96a4807002 | |||
| cff4e48b57 | |||
| 384887a862 | |||
| 4d2aef637f | |||
| c45c46abcf | |||
| 2c7495cd8d | |||
| d5fdc19016 | |||
| 10410d758c | |||
| 771ff9be34 | |||
| 29f6f6a827 | |||
| 6951c72f3c | |||
| 22e9859fdf | |||
| 6cb2fbe610 | |||
| 992f71c0e4 | |||
| 21e29564f6 | |||
| 401653a4d1 | |||
| e970e94e00 | |||
| 242ff99a83 | |||
| f2c9f34ab4 | |||
| e5945288ac | |||
| 7d1489445e | |||
| 4af4e52778 | |||
| a20da4b1a0 | |||
| edf40c9a09 | |||
| 1a8161027c | |||
| 85918c1e5d | |||
| ea714480d3 | |||
| 1d62f69ff0 | |||
| d762ecc377 | |||
| a28b75dd5b | |||
| 2b725708ef | |||
| da0a306340 | |||
| f493836b77 | |||
| 6e7a0cb493 | |||
| 76b3ff7ddf | |||
| 536061f63c | |||
| f7a12d14d2 | |||
| 3c1a98bcc4 | |||
| d591e5ed5a | |||
| 5809a470b9 | |||
| ed842d2195 | |||
| a0040ec9fb | |||
| 67b8aafd97 | |||
| ac417731d6 | |||
| 991c7e1965 | |||
| 0d9df29f58 | |||
| d54950698a | |||
| 394bd19b95 | |||
| b52d4000b4 | |||
| b32f962f11 | |||
| 0c1d3abd7e | |||
| d81564c308 | |||
| 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 |
@@ -6,8 +6,33 @@ TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE
|
|||||||
# Найти его можно в информации о боте у @BotFather.
|
# Найти его можно в информации о боте у @BotFather.
|
||||||
TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
|
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
|
# Пароль для базы данных PostgreSQL
|
||||||
POSTGRES_PASSWORD=StrongPasswordForDatabase
|
POSTGRES_PASSWORD=StrongPasswordForDatabase
|
||||||
|
|
||||||
# Локальный порт веб-интерфейса GM-Relay
|
# Локальный порт веб-интерфейса GM-Relay
|
||||||
GMRELAY_WEB_PORT=8080
|
GMRELAY_WEB_PORT=8080
|
||||||
|
|
||||||
|
# === Backup ===
|
||||||
|
# Сколько дней хранить дампы PostgreSQL (default: 7)
|
||||||
|
BACKUP_RETENTION_DAYS=7
|
||||||
|
|
||||||
|
# Имя Docker volume для резервных копий БД
|
||||||
|
BACKUP_VOLUME_NAME=game_pgbackups
|
||||||
|
|
||||||
|
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
|
||||||
|
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||||
|
|||||||
+123
-5
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.5.0
|
VERSION: 3.11.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
@@ -37,6 +37,20 @@ jobs:
|
|||||||
docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
|
docker push git.codeanddice.ru/toutsu/gmrelay-bot:latest
|
||||||
docker push git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
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
|
- name: Build Web image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
@@ -51,9 +65,78 @@ jobs:
|
|||||||
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
|
docker push git.codeanddice.ru/toutsu/gmrelay-web:latest
|
||||||
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
docker push git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||||
|
|
||||||
|
# ЧАСТЬ 1.5: Сканируем собранные образы на уязвимости
|
||||||
|
scan-images:
|
||||||
|
needs: build-and-push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.codeanddice.ru
|
||||||
|
username: toutsu
|
||||||
|
password: ${{ secrets.GIT_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install Trivy
|
||||||
|
run: |
|
||||||
|
# Install Trivy from the official Docker image instead of the
|
||||||
|
# upstream install.sh. Rationale:
|
||||||
|
# 1. install.sh resolves the positional tag against the
|
||||||
|
# GitHub releases API; when a release is unpublished or
|
||||||
|
# yanked, the script fails with
|
||||||
|
# `unable to find '<tag>' - use 'latest' or see ...`
|
||||||
|
# when the release once existed. We hit this with
|
||||||
|
# v0.71.0.
|
||||||
|
# 2. Docker Hub tags are content-addressed and rarely
|
||||||
|
# removed, so a pinned image tag is much more stable.
|
||||||
|
# 3. The image is multi-arch (linux/amd64, linux/arm64,
|
||||||
|
# linux/ppc64le, linux/s390x) so the same tag works on
|
||||||
|
# the GitHub-hosted runner and on the ARM64 Pi runner.
|
||||||
|
set -euo pipefail
|
||||||
|
TRIVY_VERSION="0.70.0"
|
||||||
|
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||||
|
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||||
|
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||||
|
docker rm trivy-tmp >/dev/null
|
||||||
|
chmod +x /usr/local/bin/trivy
|
||||||
|
trivy --version
|
||||||
|
|
||||||
|
- name: Pull images for scan
|
||||||
|
run: |
|
||||||
|
docker pull git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||||
|
docker pull git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
|
||||||
|
docker pull git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||||
|
|
||||||
|
- name: Scan Bot image
|
||||||
|
run: |
|
||||||
|
trivy image \
|
||||||
|
--timeout 30m \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--exit-code 1 \
|
||||||
|
--format table \
|
||||||
|
git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||||
|
|
||||||
|
- name: Scan Discord Bot image
|
||||||
|
run: |
|
||||||
|
trivy image \
|
||||||
|
--timeout 30m \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--exit-code 1 \
|
||||||
|
--format table \
|
||||||
|
git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
|
||||||
|
|
||||||
|
- name: Scan Web image
|
||||||
|
run: |
|
||||||
|
trivy image \
|
||||||
|
--timeout 30m \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--exit-code 1 \
|
||||||
|
--format table \
|
||||||
|
git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||||
|
|
||||||
# ЧАСТЬ 2: Запускаем эти образы на самом сервере
|
# ЧАСТЬ 2: Запускаем эти образы на самом сервере
|
||||||
deploy:
|
deploy:
|
||||||
needs: build-and-push
|
needs: scan-images
|
||||||
runs-on: ubuntu-latest # Тот же локальный раннер
|
runs-on: ubuntu-latest # Тот же локальный раннер
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -63,15 +146,50 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env
|
echo "TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }}" > .env
|
||||||
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .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_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
|
- name: Deploy Containers
|
||||||
run: |
|
run: |
|
||||||
# Авторизуемся локальным докером в нашей Gitea
|
# Авторизуемся локальным докером в нашей Gitea
|
||||||
docker login git.codeanddice.ru/ -u toutsu -p ${{ secrets.GIT_TOKEN }}
|
docker login git.codeanddice.ru/ -u toutsu -p ${{ secrets.GIT_TOKEN }}
|
||||||
|
|
||||||
# Pull гарантирует, что мы получили нужную версию.
|
# Pull гарантирует, что мы получили нужную версию.
|
||||||
docker compose pull bot web
|
docker compose pull bot discord web
|
||||||
|
|
||||||
# Запускаем! Флаг -d оставит их работать в фоне.
|
# Запускаем! Флаг -d оставит их работать в фоне.
|
||||||
docker compose up -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,100 @@
|
|||||||
|
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: |
|
||||||
|
# Install Trivy from the official Docker image instead of the
|
||||||
|
# upstream install.sh. Rationale (see deploy.yml for the long
|
||||||
|
# version): the GitHub release tag we pinned (v0.71.0) was
|
||||||
|
# unpublished, and install.sh fails hard on missing tags.
|
||||||
|
# Docker Hub images are content-addressed and rarely removed,
|
||||||
|
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
|
||||||
|
set -euo pipefail
|
||||||
|
TRIVY_VERSION="0.70.0"
|
||||||
|
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||||
|
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||||
|
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||||
|
docker rm trivy-tmp >/dev/null
|
||||||
|
chmod +x /usr/local/bin/trivy
|
||||||
|
trivy --version
|
||||||
|
|
||||||
|
- name: Trivy filesystem security scan
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
trivy fs --timeout 30m --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: |
|
||||||
|
# Exclude Testcontainers-backed PostgreSQL integration collections from PR CI.
|
||||||
|
# The ARM64 runner is too slow to reliably start Postgres containers and apply
|
||||||
|
# migrations before the default timeouts expire. These tests are still run
|
||||||
|
# locally and can be executed manually with `dotnet test`.
|
||||||
|
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj \
|
||||||
|
--filter "FullyQualifiedName!~PortfolioMigrationPostgresTests&FullyQualifiedName!~CreateSessionHandlerIntegrationTests&FullyQualifiedName!~WizardDraftRepositoryTests&FullyQualifiedName!~DbSessionTriggerStoreTests&Collection!~CreateSessionHandlerPostgresCollection" \
|
||||||
|
--verbosity normal
|
||||||
+22
@@ -12,6 +12,8 @@ publish/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.gemini/
|
.gemini/
|
||||||
|
.claude/
|
||||||
|
.serena/
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
*.DotSettings.user
|
*.DotSettings.user
|
||||||
@@ -26,3 +28,23 @@ TestResults/
|
|||||||
## Secrets
|
## Secrets
|
||||||
appsettings.*.local.json
|
appsettings.*.local.json
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
## AI working directories
|
||||||
|
docs/superpowers/
|
||||||
|
docs/plans/
|
||||||
|
*.diff
|
||||||
|
|
||||||
|
# OpenCode / Mavis / Playwright scratch dirs
|
||||||
|
.opencode/
|
||||||
|
.mavis/
|
||||||
|
.playwright-mcp/
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
# Local screenshots / diagnostic artifacts
|
||||||
|
.club-*.png
|
||||||
|
.showcase-*.png
|
||||||
|
showcase-*.png
|
||||||
|
*.png.local
|
||||||
|
deploy_log.txt
|
||||||
|
test_output.txt
|
||||||
|
src/GmRelay.Bot/Dockerfile.test
|
||||||
|
|||||||
@@ -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>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.5.0</Version>
|
<Version>3.11.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/GmRelay.AppHost/GmRelay.AppHost.csproj" />
|
<Project Path="src/GmRelay.AppHost/GmRelay.AppHost.csproj" />
|
||||||
<Project Path="src/GmRelay.Bot/GmRelay.Bot.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.ServiceDefaults/GmRelay.ServiceDefaults.csproj" />
|
||||||
<Project Path="src/GmRelay.Web/GmRelay.Web.csproj" />
|
<Project Path="src/GmRelay.Web/GmRelay.Web.csproj" />
|
||||||
</Folder>
|
</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,163 +1,231 @@
|
|||||||
# 🎲 GM-Relay: TTRPG Session Scheduling Bot & Web Dashboard
|
# 🎲 GM-Relay: TTRPG Session Scheduling Bot & Web Dashboard
|
||||||
|
|
||||||
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
|
**GM-Relay** — это комплексное решение для Мастеров Подземелий (ГМов), состоящее из высокопроизводительного Telegram-бота, Discord worker и удобного веб-интерфейса. Предназначено для автоматизации записи игроков на сессии, управления расписанием и проведения игр.
|
||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v1.5.0`.
|
**Текущая версия:** `v3.10.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Ключевые возможности
|
## ✨ Key Features
|
||||||
|
|
||||||
### 🤖 Telegram Бот
|
### 🤖 Telegram Bot
|
||||||
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением (на неделю или месяц вперед).
|
- **📅 Создание расписаний (Batch Sessions)**: Через `/newsession` бот ведёт ГМа по wizard: тип игры/пула, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка для online-игры или адрес offline-встречи, видимость и публикация.
|
||||||
|
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
|
||||||
|
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
||||||
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
||||||
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
|
- **👥 Лимит мест и лист ожидания**: ГМ задаёт максимальный состав, бот не переполняет сессию, автоматически ведёт очередь ожидания и освобождённое место отдаёт первому ожидающему.
|
||||||
- **📁 Поддержка Форумов (Telegram Topics)**: Бот автоматически создает тему во вложенных чатах Telegram под каждую новую пачку игр.
|
- **📁 Поддержка Форумов (Telegram Topics)**: Если `/newsession` запущен в теме форума Telegram, расписание и групповые уведомления остаются в этой теме; при запуске из корня форума бот создает отдельную тему и сообщает о необходимости прав admin/Manage Topics, если их не хватает.
|
||||||
- **❌ Управление сессиями**: Мастер может отменять отдельные игры прямо в общем сообщении расписания.
|
- **❌ Управление сессиями**: Owner и назначенные co-GM могут создавать, отменять, удалять и переносить игры из Telegram через `/listsessions`; публичный пост записи показывает только кнопки игроков.
|
||||||
- **🔔 Персональные уведомления**: Игроки получают DM о RSVP за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
|
- **🔄 Голосование за перенос**: Быстрый поиск свободного места с через свободное недель и кнопками новых времени и дедлайном.
|
||||||
- **🗓 Экспорт в Календарь**: Генерация файла `.ics` для добавления всех игр в Google, Apple или Яндекс Календарь одной командой.
|
- **🔔 Уведомления**: Игрок получают за 24 часа, напоминание за 1 час, ссылку перед игрой, отмены и переносы; групповые уведомления при этом остаются.
|
||||||
- **🚀 Native AOT**: Скомпилирован в нативный бинарный файл. Мгновенный запуск и минимальное потребление памяти. Идеально для **Raspberry Pi**.
|
- **🕐 Режим уведомлений 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)
|
### 🌐 Web Dashboard (Blazor Server)
|
||||||
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
|
- **🔐 Авторизация через Telegram**: Telegram Login Widget с HMAC-SHA256 валидацией.
|
||||||
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
|
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
|
||||||
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
|
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
|
||||||
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
|
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
|
||||||
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
|
||||||
- **🔄 Автоматическая синхронизация**: Любые изменения в веб-интерфейсе мгновенно обновляют сообщения с расписанием в Telegram-чатах игроков.
|
- **🧑🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
|
||||||
- **🕒 Управление временем**: UI адаптирован под московское время (UTC+3), в то время как база данных работает в UTC.
|
- **📚 Портфолио завершённых приключений**: Owner и co-GM собирают завершённые сессии в портфолио-игры на странице `/group/{id}/portfolio`, привязывают ссылки на прошедшие сессии и публикуют публичную страницу `/portfolio/{slug}` с обложкой, описанием, системой/форматом и составом мастеров.
|
||||||
|
- **⭐ Модерируемые отзывы игроков**: участники прошедших сессий могут оставить отзыв на `/portfolio/{slug}/review` с явным согласием на публикацию; мастера модерируют отзывы (`Approved`/`Rejected`/`Hidden`) в редакторе портфолио, и только одобренные отзывы видны публичной странице.
|
||||||
|
- **🖼 Обложки портфолио**: мастера загружают JPG/PNG/WEBP-обложки в редакторе портфолио; файлы сохраняются в Docker volume `portfolio_covers` и обслуживаются по пути `/portfolio-covers/{storageKey}`; конфигурация пути — `PortfolioCovers__StoragePath` в `compose.yaml`.
|
||||||
|
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового 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.
|
| Язык | C# 14 (.NET 10) |
|
||||||
- **Веб-интерфейс**: Blazor Server.
|
| Архитектура | Vertical Slice + общая библиотека `GmRelay.Shared` |
|
||||||
- **Оркестрация**: .NET Aspire (`GmRelay.AppHost`).
|
| Боты | Telegram.Bot (**Native AOT**), NetCord Gateway (Discord worker внутри `GmRelay.Bot`) |
|
||||||
- **База данных**: PostgreSQL
|
| Веб | Blazor Server |
|
||||||
- **ORM**: Dapper (с использованием Dapper.AOT для source generators).
|
| Оркестрация | .NET Aspire (`GmRelay.AppHost`) |
|
||||||
- **Миграции**: DbUp.
|
| БД | PostgreSQL |
|
||||||
- **Развертывание**: Docker Compose + Multi-arch (AMD64/ARM64).
|
| ORM | Dapper + **Dapper.AOT** (source generators) |
|
||||||
|
| Миграции | DbUp |
|
||||||
|
| Развёртывание | Docker Compose, Multi-arch (**AMD64/ARM64**) |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> При использовании Dapper в режиме Native AOT все SQL-запросы используют строго типизированные DTO; динамические типы (`dynamic`) не поддерживаются.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Быстрый старт (Docker Compose)
|
## 🚀 Быстрый старт (Docker Compose)
|
||||||
|
|
||||||
Проект использует Docker Compose для одновременного запуска базы данных, бота и веб-интерфейса.
|
**Требования:** Docker и Docker Compose.
|
||||||
|
|
||||||
### 1. Подготовка
|
|
||||||
Убедитесь, что у вас установлены **Docker** и **Docker Compose**.
|
|
||||||
|
|
||||||
### 2. Настройка окружения
|
|
||||||
Скопируйте файл-шаблон и заполните его значениями:
|
|
||||||
|
|
||||||
|
### 1. Настройка окружения
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Отредактируйте `.env`:
|
**Ключевые переменные `.env`:**
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Токен вашего бота от @BotFather (используется и для бота, и как секретный ключ для веб-авторизации)
|
# Токен от @BotFather (используется ботом и как секретный ключ веб-авторизации)
|
||||||
TELEGRAM_BOT_TOKEN=ваш_токен_здесь
|
TELEGRAM_BOT_TOKEN=ваш_токен_здесь
|
||||||
|
|
||||||
# Имя вашего бота в Telegram (без @), например: GmRelayBot.
|
# Токен Discord application bot
|
||||||
# Найти его можно в информации о боте у @BotFather.
|
DISCORD_BOT_TOKEN=ваш_discord_токен_здесь
|
||||||
# Используется для работы виджета авторизации (Telegram Login Widget).
|
|
||||||
|
# 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=ваше_имя_бота_здесь
|
TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
|
||||||
|
|
||||||
# Пароль для базы данных PostgreSQL
|
# HTTPS URL Mini App, например https://your-domain.example/miniapp
|
||||||
POSTGRES_PASSWORD=ваш_надежный_пароль
|
TELEGRAM_MINI_APP_URL=https://your-domain.example/miniapp
|
||||||
|
|
||||||
# Локальный порт веб-интерфейса GM-Relay
|
POSTGRES_PASSWORD=ваш_надежный_пароль
|
||||||
GMRELAY_WEB_PORT=8080
|
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
|
```bash
|
||||||
docker compose up -d
|
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`), затем создайте расписание: в Telegram через `/newsession` выберите `Online` и URL подключения или `Offline` и адрес места проведения; в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`.
|
||||||
|
|
||||||
Чтобы бот работал корректно:
|
## 📚 Портфолио завершённых приключений
|
||||||
1. **Добавьте бота в группу** (или Супергруппу/Форум).
|
|
||||||
2. **Назначьте бота Администратором**.
|
|
||||||
3. **Необходимые права**:
|
|
||||||
* `Выбор тем` (Managed Topics) — **обязательно** для Форумов.
|
|
||||||
* `Отправка сообщений`.
|
|
||||||
* `Закрепление сообщений` — рекомендуется.
|
|
||||||
|
|
||||||
> [!TIP]
|
Начиная с **v3.6.0** ГМы могут публиковать завершённые кампании в виде постоянных портфолио-страниц с обложкой, описанием, системой/форматом, составом мастеров и модерируемыми отзывами игроков.
|
||||||
> Колонку "Мастер" (GM) бот определяет по первому человеку, который создал сессию в этой группе. Только этот пользователь сможет отменять игры через кнопки бота и редактировать их в веб-интерфейсе.
|
|
||||||
|
|
||||||
---
|
### Возможности
|
||||||
|
|
||||||
## 📝 Инструкция для Мастера
|
- **Управление портфолио** — в `/group/{id}/portfolio` владелец и co-GM создают портфолио-игры из прошедших сессий, выбирают мастеров, заполняют описание, загружают обложку и публикуют по `public_slug`.
|
||||||
|
- **Публичная страница `/portfolio/{slug}`** — read-only карточка приключения с обложкой, описанием, составом мастеров (только публичные профили) и одобренными отзывами.
|
||||||
|
- **Отзывы участников** — на `/portfolio/{slug}/review` аутентифицированные игроки, чьи идентификаторы участвовали в одной из привязанных сессий без пометки GM, отправляют отзыв с явным согласием на публикацию; один отзыв на игрока, повторная отправка запрещена.
|
||||||
|
- **Модерация отзывов** — на странице редактора портфолио владелец/co-GM видит очередь `Pending` и переводит отзывы в `Approved`, `Rejected` или `Hidden`; только `Approved` отзывы попадают в публичную выдачу.
|
||||||
|
- **Публикация под требования** — портфолио-игра публикуется только при заполненном slug, описании, обложке, минимум одной завершённой сессии и хотя бы одном мастере группы.
|
||||||
|
|
||||||
### Создание расписания игр
|
### Хранение обложек
|
||||||
Используйте команду `/newsession` с описанием в следующем формате:
|
|
||||||
|
|
||||||
```text
|
Загруженные обложки хранятся в Docker volume `portfolio_covers` (по умолчанию имя `gmrelay_portfolio_covers`), обслуживаются веб-приложением по пути `/portfolio-covers/{storageKey}` с кешированием `Cache-Control: public, max-age=31536000, immutable`.
|
||||||
/newsession
|
|
||||||
Название: Легенды Берега Мечей (D&D 5e)
|
В `.env` можно переопределить имя volume:
|
||||||
Время: 15.05.2024 19:30
|
|
||||||
Время: 22.05.2024 19:00
|
```env
|
||||||
Мест: 4
|
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||||
Ссылка: https://discord.gg/invite-link
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Строка `Мест:` необязательна. Если она указана, игроки сверх лимита попадут в лист ожидания, а ГМ сможет повысить первого ожидающего через кнопку в Telegram или Web Dashboard.
|
В `compose.yaml` это значение пробрасывается в сервис `web` через `volumes.portfolio_covers.name`; путь к каталогу внутри контейнера — `/app/portfolio-covers` (настраивается через `PortfolioCovers__StoragePath`).
|
||||||
|
|
||||||
Игрок может самостоятельно снять запись кнопкой `🚪 Выйти` в сообщении расписания. Если он был в основном составе и в листе ожидания есть игроки, бот автоматически переводит первого ожидающего в основной состав и обновляет сообщение пачки.
|
Хранилище инкапсулировано интерфейсом `IPortfolioCoverStorage` с реализацией `LocalPortfolioCoverStorage` (файловая система), что оставляет границу для замены на S3-совместимое хранилище без изменения кода портфолио-сервисов.
|
||||||
|
|
||||||
### Bulk-операции в Web Dashboard
|
## 💾 Backup и восстановление
|
||||||
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. ГМ может:
|
|
||||||
- обновить общий `title` и `link` сразу у всех сессий batch;
|
|
||||||
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
|
|
||||||
- перенести пачку, задав новую первую дату и фиксированный шаг между играми в днях;
|
|
||||||
- клонировать batch на следующую неделю или следующий календарный месяц.
|
|
||||||
|
|
||||||
После редактирования или переноса исходное Telegram-сообщение расписания перерисовывается. При клонировании создаётся новая пачка с новым Telegram-сообщением и пустым составом игроков.
|
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
|
||||||
|
|
||||||
Если включён режим `В группе и в личку`, бот дополнительно отправляет игрокам персональные сообщения о RSVP за 24 часа, напоминание за 1 час, ссылку перед стартом, отмену и перенос. Если Telegram не позволяет написать игроку в ЛС, бот логирует ошибку и продолжает отправку остальным участникам.
|
### Как это работает
|
||||||
|
- **Каждый день в 03:00** выполняется `pg_dump` базы `gmrelay_db`.
|
||||||
|
- Дампы сжимаются (`gzip`) и сохраняются в volume `pgbackups` (`/backups`).
|
||||||
|
- Формат имени: `gmrelay_db_YYYYMMDD_HHMMSS.sql.gz`.
|
||||||
|
- Ротация: по умолчанию хранятся последние **7 дней** (настраивается через `BACKUP_RETENTION_DAYS`).
|
||||||
|
|
||||||
### Другие команды
|
### Проверка бэкапов
|
||||||
- `/listsessions` — Показать список всех актуальных игр в этой группе.
|
```bash
|
||||||
- `/reschedulesession` — Перенести сессию на другое время с голосованием игроков.
|
docker compose exec db-backup ls -la /backups
|
||||||
- `/deletesession` — Удалить сессию.
|
```
|
||||||
- `/exportcalendar` — Получить `.ics` файл с играми.
|
|
||||||
- `/help` — Справка по формату.
|
### Ручное создание дампа
|
||||||
|
```bash
|
||||||
|
docker compose exec db-backup sh -c "pg_dump -h db -U gmrelay -d gmrelay_db | gzip > /backups/gmrelay_db_manual.sql.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Восстановление из бэкапа
|
||||||
|
```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,111 @@
|
|||||||
|
## 🎯 Minor 3.10.0 — Online/offline format in /newsession wizard (issue #136)
|
||||||
|
|
||||||
|
### 🧩 Что вошло в релиз
|
||||||
|
- Telegram `/newsession` wizard теперь запрашивает формат `Online` / `Offline`.
|
||||||
|
- Для `Online` мастер вводит URL подключения; для `Offline` — адрес места проведения.
|
||||||
|
- Offline-адрес сохраняется в `sessions.location_address` через миграцию `V033__add_session_location_address.sql`.
|
||||||
|
- Telegram schedule messages показывают URL online-игры или адрес offline-встречи; Web duplicate Telegram renderer синхронизирован.
|
||||||
|
|
||||||
|
### 📦 Версия и деплой
|
||||||
|
- Версия обновлена до 3.10.0 (`Directory.Build.props`, `NavMenu.razor`, `.gitea/workflows/deploy.yml`).
|
||||||
|
- Docker-образы тегируются `3.10.0` в `compose.yaml`.
|
||||||
|
|
||||||
|
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
|
||||||
|
|
||||||
|
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync` → `PlatformNotSupportedException` → `GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
|
||||||
|
|
||||||
|
### 🩹 Что починено
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs::GetOwnerClubsAsync` — `new CommandDefinition(...)` → прямой `QueryAsync<WizardClubOption>(sql, params)`.
|
||||||
|
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs::GetOwnerClubsAsync` — то же.
|
||||||
|
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs::WizardClubLookup.LoadClubsAsync` — то же.
|
||||||
|
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs::LoadManagerUserIdsAsync` — то же.
|
||||||
|
- `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` — добавлен `<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>` (раньше был только в Shared и Bot). Без этого `Dapper.AOT`-генератор не сканировал DiscordBot, и `new CommandDefinition`-вызовы в DiscordBot падали бы в рантайме даже после фикса сигнатур.
|
||||||
|
- `src/GmRelay.DiscordBot/Program.cs` — добавлен `[module: Dapper.DapperAot]` (раньше только в Bot и Shared).
|
||||||
|
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.1 → 3.9.2.
|
||||||
|
- `tests/.../WizardDraftRepositoryAotShapeTests.cs` — расширены `ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition` на 4 inline-cases + опциональный `containingClass` для дизамбигуации одинаковых имён методов в DiscordWizardInteractionModule.
|
||||||
|
|
||||||
|
### ⚠️ Известные ограничения
|
||||||
|
- Web-проект не под NativeAOT (Blazor Server), там `Dapper.AOT` не подключён и используется обычный Dapper; регрессия его не касается.
|
||||||
|
|
||||||
|
### 🧪 Тесты
|
||||||
|
- 592/594 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит интерсепторы для всех 4 клуб-пикеров + `WizardDraftRepository` (всего 5 файлов: 4 в Bot/DiscordBot/DiscordBot + 1 в Shared).
|
||||||
|
|
||||||
|
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
|
||||||
|
|
||||||
|
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator` → `PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
|
||||||
|
|
||||||
|
### 🩹 Что починено
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — все 4 метода переписаны с `new CommandDefinition(sql, params, cancellationToken: ct)` на прямой оверлоад `connection.QuerySingleOrDefaultAsync<WizardDraft>(sql, params)` (паттерн `JoinSessionHandler`). Dapper.AOT генерирует интерсепторы только для прямого оверлоада.
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — `CreatedAt` / `UpdatedAt` / `ExpiresAt` переведены с `DateTimeOffset` на `DateTime` (UTC). AOT RowFactory вызывает `reader.GetDateTime()` напрямую и не делает `DateTime → DateTimeOffset` конверсию.
|
||||||
|
- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`, `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs` — `DateTimeOffset.UtcNow` → `DateTime.UtcNow` в новых драфтах.
|
||||||
|
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.0 → 3.9.1.
|
||||||
|
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs` — 5 source-grep регрессионных тестов: ни один метод `WizardDraftRepository` не должен использовать `new CommandDefinition`, и три timestamp-свойства `WizardDraft` должны быть `DateTime` (не `DateTimeOffset`).
|
||||||
|
|
||||||
|
### ⚠️ Известные ограничения
|
||||||
|
- В `TelegramWizardMessenger.GetOwnerClubsAsync`, `DiscordWizardMessenger.GetOwnerClubsAsync`, `DiscordPermissionLookup.LoadManagerUserIdsAsync`, `DiscordWizardInteractionModule.GetOwnerClubsAsync` остаётся `new CommandDefinition`. Эти вызовы **падают на AOT так же**, как падал `WizardDraftRepository` в 3.9.0. Пользователь натыкается на это только когда выбирает «видимость = клуб/мемберы» и доходит до шага выбора клуба. Будет исправлено в 3.9.2 вместе с переводом `DiscordWizardInteractionModule` на прямые Dapper-оверлоады.
|
||||||
|
|
||||||
|
### 🧪 Тесты
|
||||||
|
- 588/590 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит 4 интерсептора + `RowFactory17<WizardDraft>` + `CommandFactory30<WizardDraft>`.
|
||||||
|
|
||||||
|
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
|
||||||
|
|
||||||
|
Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда.
|
||||||
|
|
||||||
|
### 🧩 Что вошло в релиз
|
||||||
|
|
||||||
|
**Платформо-нейтральный рефакторинг (GmRelay.Shared)**
|
||||||
|
- `Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs` — стейт-машина визарда (один источник правды для обеих платформ)
|
||||||
|
- `Features/Sessions/CreateSession/Wizard/IWizardMessenger.cs` — контракт мессенджера (edit/send/answer/getOwnerClubs)
|
||||||
|
- `Features/Sessions/CreateSession/Wizard/WizardInteraction.cs` — запись взаимодействия (OwnerId, Text, CallbackPayload, PhotoFileId, PhotoUrl, InteractionId)
|
||||||
|
- `Features/Sessions/CreateSession/Wizard/WizardAction.cs`, `WizardKeyboard.cs`, `WizardStepLimits.cs` — модель кнопок и лимитов
|
||||||
|
- `Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — добавлено поле `Platform`
|
||||||
|
- `Migrations/V032__add_wizard_drafts_platform.sql` — `ALTER TABLE wizard_drafts ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
|
||||||
|
|
||||||
|
**Discord-адаптер (GmRelay.DiscordBot)**
|
||||||
|
- `Features/Sessions/Wizard/DiscordWizardCommand.cs` — slash-команда `/newsession-wizard` с проверкой owner/co-GM через `DiscordPermissionLookup`
|
||||||
|
- `Features/Sessions/Wizard/DiscordWizardStep.cs` — рендер 15 шагов в NetCord embed + buttons/StringSelectMenu/modals
|
||||||
|
- `Features/Sessions/Wizard/DiscordWizardMessenger.cs` — реализация `IWizardMessenger` через NetCord REST (edit с fallback на re-send при 401/403/404)
|
||||||
|
- `Features/Sessions/Wizard/DiscordWizardSubmitter.cs` — финализация с 3-retry циклом
|
||||||
|
- `Features/Sessions/Wizard/DiscordWizardContextStore.cs` — in-memory кэш контекста (guild/channel/messageId) для 15-минутного interaction token
|
||||||
|
- `Features/Sessions/Wizard/DiscordWizardInteractionModule.cs` — inbound handlers: 3 NetCord `ComponentInteractionModule<TContext>` (button/StringMenu/Modal) + `WizardInteractionDispatcher`
|
||||||
|
- `Features/Sessions/Wizard/DiscordPermissionLookup.cs` — DB-хелпер для `group_managers`
|
||||||
|
- `Program.cs` — DI-регистрации + 3 `AddComponentInteractions<TInteraction, TContext>`
|
||||||
|
|
||||||
|
**Тесты**
|
||||||
|
- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*` — обновлены под новый контракт
|
||||||
|
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs` — 12 source-level smoke-тестов на структуру interaction module
|
||||||
|
|
||||||
|
### 🗺 Что это даёт
|
||||||
|
- Мастера (GM) могут пошагово создавать игры и пулы слотов прямо в Discord через slash-команду, кнопки, выпадающие меню и модальные окна.
|
||||||
|
- UX адаптирован под Discord (нативные components), а не скопирован из Telegram.
|
||||||
|
- Общая стейт-машина и валидация: Telegram и Discord визарды развиваются синхронно, баги фиксятся в одном месте.
|
||||||
|
- PickClub-шаг использует реальный SQL-запрос к `club_memberships` с фильтром по роли Owner/CoGm.
|
||||||
|
|
||||||
|
### 📦 Версия и деплой
|
||||||
|
- Версия обновлена до 3.9.0 (`NavMenu.razor`, `.gitea/workflows/deploy.yml`)
|
||||||
|
- Docker-образы будут тегированы `3.9.0` при пуше в `main`
|
||||||
|
- Миграция V032 применяется автоматически на старте Bot
|
||||||
|
|
||||||
|
## 🛠 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
|
||||||
+77
-3
@@ -16,8 +16,40 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
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:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.5.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -25,31 +57,73 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
- "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__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||||
|
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
|
||||||
networks:
|
networks:
|
||||||
- gmrelay
|
- gmrelay
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
web:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.5.0
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
bot:
|
||||||
|
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:3.11.0
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
bot:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
|
- "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__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||||
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME 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:-}"
|
||||||
|
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
|
||||||
ports:
|
ports:
|
||||||
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- web_keys:/app/dataprotection-keys
|
- web_keys:/app/dataprotection-keys
|
||||||
|
- portfolio_covers:/app/portfolio-covers
|
||||||
networks:
|
networks:
|
||||||
- gmrelay
|
- gmrelay
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
|
name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
|
||||||
web_keys:
|
web_keys:
|
||||||
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
|
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
|
||||||
|
pgbackups:
|
||||||
|
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
|
||||||
|
portfolio_covers:
|
||||||
|
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
gmrelay:
|
gmrelay:
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
# Discord wizard adapter — issue #112
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implemented the Discord side of the platform-neutral game/pool creation
|
||||||
|
wizard (`feat/issue-112-wizard-refactor`). Six adapter files in
|
||||||
|
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/` plus one inbound
|
||||||
|
handler module turn the `/newsession-wizard` slash command into a
|
||||||
|
fully clickable step-by-step wizard with button, StringSelectMenu, and
|
||||||
|
modal handlers. The shared `GameCreationWizard` state machine in
|
||||||
|
`GmRelay.Shared` is the single source of truth — the Discord adapter
|
||||||
|
only translates between NetCord's interaction types and the
|
||||||
|
platform-neutral `WizardInteraction` record.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`):
|
||||||
|
|
||||||
|
- `DiscordWizardContextStore.cs` — `IWizardContextStore` interface +
|
||||||
|
thread-safe in-memory store. Keyed by `draft.Id`. Holds the
|
||||||
|
`(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger
|
||||||
|
needs to re-send a draft after a 15-minute interaction token
|
||||||
|
expires.
|
||||||
|
- `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns
|
||||||
|
an embed + `IReadOnlyList<IMessageComponentProperties>` (mixes
|
||||||
|
`ActionRow` buttons with `StringMenu` select menus) and exposes
|
||||||
|
`BuildModal` for the 8 modal-collecting steps.
|
||||||
|
- `DiscordWizardMessenger.cs` — `IWizardMessenger` impl. Uses
|
||||||
|
`RestClient.SendMessageAsync` / `ModifyMessageAsync` (edit falls
|
||||||
|
back to re-send on 401/403/404). Toast replies are stashed in the
|
||||||
|
existing `DiscordInteractionReplyCache`.
|
||||||
|
- `DiscordWizardSubmitter.cs` — 3-retry finalize loop. Builds the
|
||||||
|
shared `CreateSessionCommand` and calls `CreateSessionHandler`;
|
||||||
|
on success edits the draft to "✅ Создано: N сессий", on failure
|
||||||
|
shows retry/cancel buttons.
|
||||||
|
- `DiscordWizardCommand.cs` — `/newsession-wizard` slash command.
|
||||||
|
Owner/co-GM check via `group_managers` (DiscordPermissionLookup).
|
||||||
|
- `DiscordPermissionLookup.cs` — small DB helper that loads
|
||||||
|
`group_managers` rows for a guild.
|
||||||
|
- `DiscordWizardInteractionModule.cs` — **inbound handlers** (this
|
||||||
|
commit). Three NetCord `ComponentInteractionModule<TContext>`
|
||||||
|
shells (button / StringSelectMenu / modal) share one
|
||||||
|
`WizardInteractionDispatcher` that:
|
||||||
|
1. parses the custom-id tail (`btn:choice:<step>:<value>`,
|
||||||
|
`btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`,
|
||||||
|
`btn:resume:restart`, `select:<step>`, `modal:<step>`);
|
||||||
|
2. loads the active draft via
|
||||||
|
`IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct)`;
|
||||||
|
3. routes the callback through the shared
|
||||||
|
`GameCreationWizard.HandleInteractionAsync` (or
|
||||||
|
`DiscordWizardSubmitter.SubmitAsync` for the `create` button);
|
||||||
|
4. opens a modal popup when the new step needs text input,
|
||||||
|
otherwise acks with a deferred message so Discord doesn't show
|
||||||
|
"Application did not respond".
|
||||||
|
|
||||||
|
DI / tests:
|
||||||
|
|
||||||
|
- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations
|
||||||
|
(`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`,
|
||||||
|
`GameCreationWizard`, `DiscordWizardSubmitter`,
|
||||||
|
`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
|
||||||
|
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus
|
||||||
|
3 `AddComponentInteractions<TInteraction, TContext>` calls
|
||||||
|
(Button, StringMenu, Modal).
|
||||||
|
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
|
||||||
|
— 12 source-level structural smoke tests: handler classes exist,
|
||||||
|
all 3 derive from `ComponentInteractionModule<TContext>`, all 3
|
||||||
|
register `[ComponentInteraction("wizard")]`, the dispatcher
|
||||||
|
exposes `HandleButtonAsync` / `HandleStringMenuAsync` /
|
||||||
|
`HandleModalAsync`, all 5 callback kinds (`choice` / `back` /
|
||||||
|
`cancel` / `create` / `resume`) are routed, the dispatcher
|
||||||
|
invokes `GameCreationWizard.HandleInteractionAsync` and
|
||||||
|
`DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs
|
||||||
|
registers all 3 `AddComponentInteractions` and all 4 module
|
||||||
|
classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal
|
||||||
|
walks `Components[0] → TextInput → .Value`, string menu reads
|
||||||
|
`SelectedValues[0]`.
|
||||||
|
|
||||||
|
## Custom-id wire format
|
||||||
|
|
||||||
|
| Interaction | Custom-id | Handler |
|
||||||
|
|------------------------|------------------------------------------|-------------------------------------------------|
|
||||||
|
| Choice button | `wizard:btn:choice:<step>:<value>` | Wizard's `ApplyChoice` |
|
||||||
|
| Back button | `wizard:btn:back` | Wizard's `ApplyBack` |
|
||||||
|
| Cancel button | `wizard:btn:cancel` | Wizard deletes draft + edits "❌ Мастер отменён" |
|
||||||
|
| Create button | `wizard:btn:create` | `DiscordWizardSubmitter.SubmitAsync` (3 retries) |
|
||||||
|
| Resume: continue | `wizard:btn:resume:continue` | Re-render current step via messenger |
|
||||||
|
| Resume: restart | `wizard:btn:resume:restart` | Delete draft, prompt to re-run |
|
||||||
|
| StringSelectMenu | `wizard:select:<step>` | Wizard's `ApplyChoice` (step, SelectedValues[0]) |
|
||||||
|
| Modal submit | `wizard:modal:<step>` | Wizard's `ApplyText` (Text = Component[0].Value) |
|
||||||
|
|
||||||
|
The wizard renderer (`DiscordWizardStep`) owns the prefix generation:
|
||||||
|
`wizard:btn:<step>:<value>`, `wizard:select:<step>`,
|
||||||
|
`wizard:modal:<step>`. The handlers match by the `wizard` prefix and
|
||||||
|
parse the rest.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- ✅ `dotnet build` решения — 0 warnings, 0 errors
|
||||||
|
- ✅ `dotnet test` — 190/190 Discord+Wizard tests pass (2 pre-existing
|
||||||
|
skipped). 12 source-level smoke tests cover the interaction module.
|
||||||
|
- ✅ `dotnet format --verify-no-changes` — clean
|
||||||
|
- ✅ Pushed to `feat/issue-112-wizard-refactor` (commits `b81d865`,
|
||||||
|
`f095209`).
|
||||||
|
|
||||||
|
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- ~~**Club lookup at the PickClub step**~~ — **FIXED in commit `7cfb196`.**
|
||||||
|
`WizardClubLookup.LoadClubsAsync` now queries `group_managers`
|
||||||
|
directly via injected `NpgsqlDataSource` with the same
|
||||||
|
`Owner | CoGm` role filter the messenger uses. The dispatcher
|
||||||
|
reads the owner's real club list and renders them in the
|
||||||
|
StringSelectMenu. Build green, 12 source-level smoke tests still
|
||||||
|
pass.
|
||||||
|
- ~~**MaybeOpenModalAsync was a no-op in the previous commit**~~ — **FIXED in commit `f095209`.**
|
||||||
|
The dispatcher now runs the wizard first (which edits the draft
|
||||||
|
embed), then sends the response as either
|
||||||
|
`InteractionCallback.Modal(modalProperties)` (when the new step
|
||||||
|
needs text input) or `InteractionCallback.DeferredMessage()`
|
||||||
|
(otherwise). NetCord locks the response type after the first
|
||||||
|
`SendResponseAsync` call, so the fix is NOT to call
|
||||||
|
`DeferredMessage` upfront.
|
||||||
|
- **Modal handler's free-text mapping is a hack.** Modal steps like
|
||||||
|
`SystemFreeText`, `DurationFreeText`, `PoolSystemDurationFreeText`
|
||||||
|
are mapped to the canonical wizard step (`System`, `Duration`,
|
||||||
|
`PoolSystemDuration`) in `MapModalStepToWizardStep`. This works
|
||||||
|
because the wizard's `ApplyText` dispatches on the canonical step
|
||||||
|
name, but a future refactor of `ApplyText` to know about the
|
||||||
|
free-text step names would break this. The clean fix is to add
|
||||||
|
dedicated "free text" steps to `WizardStepNames`.
|
||||||
|
- **Resume:continue is a re-render, not a true resume.** The wizard
|
||||||
|
has no special resume case; clicking "▶️ Продолжить" just re-emits
|
||||||
|
the current step's embed. This is fine for the user (the embed is
|
||||||
|
identical to the last one they saw before the click), but the
|
||||||
|
underlying state isn't really "continued" — if the wizard's cleanup
|
||||||
|
service expired the draft between the slash command and the
|
||||||
|
click, the user gets a re-render of an empty step.
|
||||||
|
- **One-draft-per-owner invariant.** The wizard's "one active draft
|
||||||
|
per owner" rule means a single Discord user can't run two wizard
|
||||||
|
sessions in parallel. Acceptable for now, but the wizard's state
|
||||||
|
machine doesn't enforce this — only the dispatcher does, via
|
||||||
|
`GetActiveAsync("Discord", ownerId)`.
|
||||||
|
|
||||||
|
## Final commit history on `feat/issue-112-wizard-refactor`
|
||||||
|
|
||||||
|
- `8f0f2ef` — Task 1: platform-neutral wizard refactor (core + Shared types)
|
||||||
|
- `b81d865` — Task 2: Discord adapter scaffolding (messenger, step, submitter, command)
|
||||||
|
- `f095209` — Task 2: interaction module + modal popup fix
|
||||||
|
- `7cfb196` — Task 2: select/modal parser off-by-one + real club lookup
|
||||||
|
|
||||||
|
PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
|
||||||
@@ -56,8 +56,18 @@ Aspire обеспечивает:
|
|||||||
- Service discovery и передачу connection strings.
|
- Service discovery и передачу connection strings.
|
||||||
- OpenTelemetry (traces, metrics, logs) из коробки.
|
- OpenTelemetry (traces, metrics, logs) из коробки.
|
||||||
- Aspire Dashboard для мониторинга.
|
- 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.
|
- Long Polling — единственный вариант для Pi за NAT.
|
||||||
- Telegram.Bot поддерживает `System.Text.Json` source generators для AOT.
|
- 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.
|
||||||
+146
-39
@@ -1,4 +1,4 @@
|
|||||||
# GM-Relay — C4 Model
|
# GM-Relay - C4 Model
|
||||||
|
|
||||||
## Level 1: System Context
|
## Level 1: System Context
|
||||||
|
|
||||||
@@ -6,19 +6,27 @@
|
|||||||
C4Context
|
C4Context
|
||||||
title GM-Relay System Context
|
title GM-Relay System Context
|
||||||
|
|
||||||
Person(gm, "Game Master", "Создаёт сессии, управляет расписанием игр")
|
Person(gm, "Game Master", "Creates sessions and manages schedules")
|
||||||
Person(player, "Player", "Подтверждает участие через inline-кнопки")
|
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
|
||||||
|
Person(visitor, "Public visitor", "Views published club schedules, sessions, GM profiles, and completed-adventure portfolio pages 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/session/GM profile/portfolio pages, and shared scheduling logic")
|
||||||
|
|
||||||
System_Ext(telegram, "Telegram Bot API", "Long Polling. Сообщения, inline keyboards, callback queries.")
|
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
|
||||||
SystemDb_Ext(postgres, "PostgreSQL", "Сессии, игроки, RSVP-статусы")
|
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, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, cover_storage_keys")
|
||||||
|
|
||||||
Rel(gm, telegram, "Команды бота (/newsession)")
|
Rel(gm, telegram, "Creates and manages sessions")
|
||||||
Rel(player, telegram, "Нажимает кнопки (✅ Буду / ❌ Не смогу)")
|
Rel(gm, discord, "Uses /newsession and /listsessions")
|
||||||
Rel(telegram, gmrelay, "Updates (Long Polling)")
|
Rel(player, telegram, "Uses inline buttons")
|
||||||
|
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
|
||||||
|
Rel(player, gmrelay, "Submits moderated reviews for completed-adventure portfolios")
|
||||||
|
Rel(visitor, gmrelay, "Views public club, session, GM profile, and portfolio pages")
|
||||||
|
Rel(telegram, gmrelay, "Updates via long polling")
|
||||||
|
Rel(discord, gmrelay, "Gateway events and component interactions")
|
||||||
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
|
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
|
## Level 2: Container
|
||||||
@@ -29,50 +37,149 @@ C4Container
|
|||||||
|
|
||||||
Person(gm, "Game Master")
|
Person(gm, "Game Master")
|
||||||
Person(player, "Player")
|
Person(player, "Player")
|
||||||
|
Person(visitor, "Public visitor")
|
||||||
|
|
||||||
System_Boundary(pi, "Raspberry Pi 5") {
|
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
|
||||||
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Long polling, обработка команд и callback queries, планировщик")
|
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
|
||||||
ContainerDb(db, "PostgreSQL 16", "Database", "sessions, players, session_participants, game_groups")
|
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/session/GM profile/portfolio pages, portfolio review submission and moderation, 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, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, platform identities")
|
||||||
}
|
}
|
||||||
|
|
||||||
System_Ext(telegram, "Telegram Bot API")
|
System_Ext(telegram, "Telegram Bot API")
|
||||||
|
System_Ext(discord, "Discord Gateway and REST API")
|
||||||
|
SystemDb_Ext(covers, "Portfolio covers volume", "Persistent file store for portfolio cover uploads (LocalPortfolioCoverStorage; S3-compatible replacement boundary)")
|
||||||
|
|
||||||
Rel(gm, telegram, "Commands")
|
Rel(gm, telegram, "Commands")
|
||||||
Rel(player, telegram, "Callback Queries")
|
Rel(gm, discord, "Slash commands")
|
||||||
Rel(telegram, bot, "GetUpdates (Long Polling)")
|
Rel(player, telegram, "Callback queries")
|
||||||
|
Rel(player, discord, "Button interactions")
|
||||||
|
Rel(player, web, "Submits moderated reviews on completed-adventure portfolio pages")
|
||||||
|
Rel(visitor, web, "Read-only public schedule, sanitized GM profile, and completed-adventure portfolio pages")
|
||||||
|
Rel(telegram, bot, "GetUpdates")
|
||||||
|
Rel(discord, discordBot, "Gateway events")
|
||||||
Rel(bot, telegram, "Bot API calls")
|
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(bot, db, "Npgsql + Dapper.AOT")
|
||||||
|
Rel(discordBot, db, "Npgsql + Dapper")
|
||||||
|
Rel(web, db, "Npgsql + Dapper")
|
||||||
|
Rel(web, covers, "Saves, reads, and deletes cover files via IPortfolioCoverStorage")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Level 3: Component (GmRelay.Bot)
|
## Level 3: Component - Session Interactions
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
C4Component
|
C4Component
|
||||||
title GmRelay.Bot Components
|
title Platform-Neutral Session Interactions
|
||||||
|
|
||||||
Container_Boundary(bot, "GmRelay.Bot") {
|
Container_Boundary(shared, "GmRelay.Shared") {
|
||||||
Component(polling, "TelegramBotService", "BackgroundService", "Long polling loop, получает Updates")
|
Component(join, "JoinSessionHandler", "Feature handler", "Adds players as Active or Waitlisted with session row locking")
|
||||||
Component(router, "UpdateRouter", "C#", "Маршрутизирует Update → Handler по типу")
|
Component(leave, "LeaveSessionHandler", "Feature handler", "Removes players and promotes the first waitlisted player when capacity allows")
|
||||||
Component(scheduler, "SessionSchedulerService", "BackgroundService", "PeriodicTimer(60s): T-24ч и T-5мин триггеры")
|
Component(rsvp, "HandleRsvpHandler", "Feature handler", "Updates RSVP state and emits platform-neutral RSVP outcomes")
|
||||||
Component(migrator, "DbMigrator", "DbUp", "SQL миграции при старте")
|
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(confirm, "SendConfirmationHandler", "Feature", "Отправляет inline keyboard за 24ч")
|
Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions")
|
||||||
Component(rsvp, "HandleRsvpHandler", "Feature", "Обрабатывает ✅/❌, проверяет all-confirmed")
|
|
||||||
Component(link, "SendJoinLinkHandler", "Feature", "Отправляет join link за 5 мин")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
System_Ext(telegram, "Telegram Bot API")
|
Component(healthCheck, "DiscordHealthCheckHostedService", ":8082", "Healthcheck для Docker Compose")
|
||||||
ContainerDb(db, "PostgreSQL")
|
|
||||||
|
|
||||||
Rel(polling, router, "Update")
|
Container_Boundary(discordBot, "GmRelay.DiscordBot") {
|
||||||
Rel(router, rsvp, "CallbackQuery rsvp:*")
|
Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session/rsvp buttons to neutral commands")
|
||||||
Rel(scheduler, confirm, "T-24h trigger")
|
Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Sends and edits Discord schedule, RSVP, reminder, join-link, and reschedule messages")
|
||||||
Rel(scheduler, link, "T-5min trigger")
|
}
|
||||||
Rel(confirm, telegram, "SendMessage + InlineKeyboard")
|
|
||||||
Rel(rsvp, telegram, "EditMessage + AnswerCallback")
|
Container_Boundary(bot, "GmRelay.Bot") {
|
||||||
Rel(link, telegram, "SendMessage + user mentions")
|
Component(updateRouter, "UpdateRouter", "Telegram adapter", "Maps callback queries to neutral commands")
|
||||||
Rel(confirm, db, "SELECT/UPDATE sessions")
|
Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Sends and edits Telegram schedule, RSVP, reminder, join-link, and reschedule messages")
|
||||||
Rel(rsvp, db, "UPDATE participants, SELECT counts")
|
}
|
||||||
Rel(link, db, "SELECT confirmed players")
|
|
||||||
Rel(migrator, db, "DDL migrations")
|
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")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Level 3: Component - Completed-Adventure Portfolios
|
||||||
|
|
||||||
|
The portfolio subsystem lets GMs curate completed adventures from past sessions, publish a public detail page, and collect moderated player reviews. The cover files live in a persistent volume via the `IPortfolioCoverStorage` boundary; the public schema and contracts are isolated inside `GmRelay.Web.Services.Portfolio` so a future S3-compatible storage adapter can replace `LocalPortfolioCoverStorage` without touching the data layer.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
C4Component
|
||||||
|
title Completed-Adventure Portfolio Subsystem
|
||||||
|
|
||||||
|
Person(gm, "Game Master", "Curates completed adventures and moderates reviews")
|
||||||
|
Person(player, "Player", "Submits one moderated review per completed adventure")
|
||||||
|
Person(visitor, "Public visitor", "Reads public portfolio pages and approved reviews")
|
||||||
|
|
||||||
|
Container_Boundary(web, "GmRelay.Web") {
|
||||||
|
Component(authorized, "AuthorizedPortfolioService", "Feature service", "Manager authorization, review submission authorization, identity resolution, cover cleanup orchestration")
|
||||||
|
Component(store, "PortfolioService", "Feature service", "Portfolio CRUD, public reads, review submission, moderation; SQL via Dapper.AOT and advisory locks")
|
||||||
|
Component(covers, "IPortfolioCoverStorage", "Storage boundary", "LocalPortfolioCoverStorage saves/reads/deletes cover files; S3-compatible replacement boundary")
|
||||||
|
Component(pages, "PublicPortfolio.razor", "Blazor page", "Renders /portfolio/{slug} and review form for participants")
|
||||||
|
Component(editor, "PortfolioEditor.razor", "Blazor page", "Renders /group/{id}/portfolio editor, cover upload, and review moderation queue")
|
||||||
|
}
|
||||||
|
|
||||||
|
ContainerDb(db, "PostgreSQL")
|
||||||
|
ContainerDb_Ext(coversVolume, "portfolio_covers volume", "Persistent file store for cover uploads")
|
||||||
|
|
||||||
|
Rel(gm, editor, "Creates, edits, publishes, moderates reviews")
|
||||||
|
Rel(player, pages, "Submits review")
|
||||||
|
Rel(visitor, pages, "Reads public portfolio and approved reviews")
|
||||||
|
Rel(pages, authorized, "GetReviewSubmissionStateForCurrentUserAsync, SubmitReviewForCurrentUserAsync")
|
||||||
|
Rel(pages, store, "GetPublicPortfolioGamesForClubAsync, GetPublicPortfolioGamesForMasterAsync, GetPublicPortfolioGameBySlugAsync")
|
||||||
|
Rel(editor, authorized, "GetPortfolioGamesForCurrentUserAsync, CreateDraftForCurrentUserAsync, UpdateDraftForCurrentUserAsync, ReplaceCoverForCurrentUserAsync, SetPublicationForCurrentUserAsync, ModerateReviewForCurrentUserAsync")
|
||||||
|
Rel(authorized, store, "All manager-gated reads/writes; identity and group authorization")
|
||||||
|
Rel(authorized, covers, "Save, read, delete cover files")
|
||||||
|
Rel(authorized, sessionStore, "ISessionStore.IsGroupManagerAsync / ResolveEffectivePlayerIdAsync")
|
||||||
|
Rel(store, db, "INSERT/UPDATE/SELECT on portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews")
|
||||||
|
Rel(covers, coversVolume, "Filesystem reads/writes")
|
||||||
|
Rel(editor, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
|
||||||
|
Rel(pages, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Portfolio tables (PostgreSQL)
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `portfolio_games` | Adventure header: `title`, `description`, `system`, `format`, `public_slug`, `cover_storage_key`, `completed_at`, `is_public`, `published_at` |
|
||||||
|
| `portfolio_game_sessions` | Many-to-many link from `portfolio_games` to past `sessions` used to assemble the adventure |
|
||||||
|
| `portfolio_game_masters` | Many-to-many link from `portfolio_games` to `players` who are managers of the source group |
|
||||||
|
| `portfolio_game_reviews` | Player reviews: `author_player_id`, `author_display_name`, `body`, `publication_consent_at`, `moderation_status` (`Pending` / `Approved` / `Rejected` / `Hidden`), `moderated_by_player_id`, `moderated_at` |
|
||||||
|
|
||||||
|
### Cover storage boundary
|
||||||
|
|
||||||
|
- `IPortfolioCoverStorage` is registered as a DI singleton in `GmRelay.Web`.
|
||||||
|
- The current implementation `LocalPortfolioCoverStorage` writes under `PortfolioCovers:StoragePath` (default `/app/portfolio-covers`) and is mounted as the Docker volume `portfolio_covers` (configurable via `PORTFOLIO_COVERS_VOLUME_NAME` in `.env`).
|
||||||
|
- Static files are served by the web container at `/portfolio-covers/{storageKey}` with `Cache-Control: public, max-age=31536000, immutable`.
|
||||||
|
- Replacing the local filesystem with S3-compatible object storage is a contract-only change: implement `IPortfolioCoverStorage` with the same `SaveAsync` / `GetPublicPath` / `DeleteIfExistsAsync` surface and swap the DI registration in `PortfolioCoverStorageExtensions.AddPortfolioCoverStorage`.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Detailed code review plan for the Discord wizard feature branch.
|
||||||
|
|
||||||
|
Read this file FIRST. It has the full review scope. The original prompt
|
||||||
|
in the spawn was truncated due to Windows CLI limits; this file is the
|
||||||
|
canonical spec.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Branch to review
|
||||||
|
BRANCH = "feat/issue-112-wizard-refactor"
|
||||||
|
BASE = "origin/main"
|
||||||
|
|
||||||
|
# Files of interest
|
||||||
|
SHARED = "src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/*"
|
||||||
|
BOT_WIZARD = "src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/*"
|
||||||
|
BOT_CREATE = "src/GmRelay.Bot/Features/Sessions/CreateSession/*"
|
||||||
|
MIGRATION = "src/GmRelay.Bot/Migrations/V032__add_wizard_drafts_platform.sql"
|
||||||
|
DISCORD = "src/GmRelay.DiscordBot/Features/Sessions/Wizard/*"
|
||||||
|
PROG_CS = "src/GmRelay.DiscordBot/Program.cs"
|
||||||
|
TESTS_WIZ = "tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/*"
|
||||||
|
TESTS_SMOKE = "tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs"
|
||||||
|
DELIVERABLE = "deliverable.md"
|
||||||
|
|
||||||
|
REVIEW_FOCUS = [
|
||||||
|
"Architecture: Shared/Bot/DiscordBot separation; no Telegram.Bot in Shared; no NetCord in Shared; single state machine source.",
|
||||||
|
"Security: owner/co-GM checks everywhere; NRE on null Context.User; SQL injection; connection strings with passwords.",
|
||||||
|
"Correctness: AOT-safety (no reflection, no dynamic); off-by-one in customId parsers; CancellationToken/Services.",
|
||||||
|
"Style: naming consistent; Async/await by convention; logging at right levels.",
|
||||||
|
"Tests: smoke tests are string-matching — where would real tests be useful?",
|
||||||
|
"Migration safety: V032 DEFAULT value, will it fail on existing rows?",
|
||||||
|
"Documentation: deliverable.md updated, open questions listed?",
|
||||||
|
]
|
||||||
|
|
||||||
|
OUTPUT_FORMAT = """\
|
||||||
|
## VERDICT: APPROVE / REQUEST_CHANGES / COMMENT
|
||||||
|
|
||||||
|
## Critical findings
|
||||||
|
(file:line — what's wrong — how to fix)
|
||||||
|
|
||||||
|
## Important findings
|
||||||
|
(file:line — what's wrong)
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
(quick observations)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
(1-2 sentences)
|
||||||
|
"""
|
||||||
|
|
||||||
|
COMMANDS_HINT = """\
|
||||||
|
git fetch origin
|
||||||
|
git diff origin/main..feat/issue-112-wizard-refactor --stat
|
||||||
|
git diff origin/main..feat/issue-112-wizard-refactor
|
||||||
|
dotnet build && dotnet test
|
||||||
|
"""
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# Code review — feat/issue-112-wizard-refactor (issue #112)
|
||||||
|
|
||||||
|
**Reviewer:** Verifier (mvs_86868b01387b492aae27ce6f77aca4cb)
|
||||||
|
**Branch:** `feat/issue-112-wizard-refactor` (base `origin/main`)
|
||||||
|
**Commits reviewed:** `8f0f2ef`, `b81d865`, `f095209`, `7cfb196`, `c4a77d3`
|
||||||
|
**Build:** ✅ `dotnet build GM-Relay.slnx` — 0 warnings, 0 errors
|
||||||
|
**Tests:** 580 passed / 2 skipped / 1 failed. 1 failure is the pre-existing
|
||||||
|
`DiscordProjectStructureTests.Version_ShouldBeSynchronizedForDiscordFeatureRelease`
|
||||||
|
(uncommitted release work in working tree, not part of this branch).
|
||||||
|
|
||||||
|
## VERDICT: REQUEST_CHANGES
|
||||||
|
|
||||||
|
The branch is **NOT shippable in its current state.** Every choice button
|
||||||
|
and every "Другое…" button in the wizard is silently broken at runtime
|
||||||
|
due to a wire-format mismatch between the renderer and the dispatcher.
|
||||||
|
A user who clicks "D&D 5e", "Pathfinder 2e", "Waitlist вкл", "Опубликовать",
|
||||||
|
or any "Другое… ✏️" button will see "⚠️ Неизвестная кнопка" instead of
|
||||||
|
the wizard advancing. The 12 source-level smoke tests don't catch this
|
||||||
|
because they only check string presence in source code, not the actual
|
||||||
|
button-click → dispatch flow.
|
||||||
|
|
||||||
|
The architecture is otherwise sound: no Telegram.Bot/NetCord leak into
|
||||||
|
Shared, single state-machine source, all DI wired, AOT-safe, parameterized
|
||||||
|
SQL, owner/co-GM permission check with null-safety, SecretRedactor on the
|
||||||
|
connection string. The fix is small and surgical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical findings
|
||||||
|
|
||||||
|
### C-1. Choice-button custom-id is missing the `choice:` segment — wizard is unusable end-to-end
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:79-80`
|
||||||
|
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:174-226`
|
||||||
|
|
||||||
|
**What's wrong.** The dispatcher's button handler matches `parts[1]`
|
||||||
|
against `"choice"`, `"back"`, `"cancel"`, `"create"`, `"resume"`, and
|
||||||
|
falls through to "default → Неизвестная кнопка" for anything else. The
|
||||||
|
dispatcher's own documentation and the deliverable's wire-format table
|
||||||
|
both agree the canonical choice-button format is
|
||||||
|
`wizard:btn:choice:<step>:<value>`. But `ButtonCustomId` emits
|
||||||
|
`$"wizard:btn:{step}:{value}"` — **the literal `choice:` segment is
|
||||||
|
missing**. So clicking "D&D 5e" on the System step produces
|
||||||
|
`wizard:btn:System:Dnd5e`, which NetCord strips the `[ComponentInteraction("wizard")]`
|
||||||
|
prefix from, arriving at the dispatcher as `args = "btn:System:Dnd5e"` →
|
||||||
|
`parts = ["btn", "System", "Dnd5e"]` → `parts[1] = "System"` → default
|
||||||
|
branch → "⚠️ Неизвестная кнопка".
|
||||||
|
|
||||||
|
The same bug hits:
|
||||||
|
- `RenderType` — "Одну игру" / "Пул игр" buttons (emits
|
||||||
|
`wizard:btn:Type:single`, `wizard:btn:Type:pool`)
|
||||||
|
- `RenderSystem` — D&D/Pathfinder/CoC/GURPS/Fate ("wizard:btn:System:Dnd5e" etc.)
|
||||||
|
**and** the "⏭ Пропустить" button (emits `wizard:btn:System:_skip`)
|
||||||
|
- `RenderDuration` — "3 часа" / "4 часа" / "5 часов" / "6 часов" and
|
||||||
|
"⏭ Пропустить"
|
||||||
|
- `RenderCapacity` / `RenderPoolSlotCapacity` — "Waitlist вкл" / "Без waitlist"
|
||||||
|
(emits `wizard:btn:Capacity:waitlist:on` etc.)
|
||||||
|
- `RenderPublish` — "Опубликовать" / "Только в чате"
|
||||||
|
- `RenderPoolAddSlots` — "Добавить слот" / "Готово, к превью"
|
||||||
|
- `RenderPickClub` — back/cancel still work (parts[1] = "back"/"cancel")
|
||||||
|
|
||||||
|
Only back, cancel, create, resume, and the "Другое… ✏️" → modal buttons
|
||||||
|
are unaffected by *this specific* bug (see C-2 for modal buttons).
|
||||||
|
|
||||||
|
The smoke test `Dispatcher_ShouldParseAllWizardActionKinds`
|
||||||
|
(`tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs:85-97`)
|
||||||
|
checks that the strings `"choice"`, `"back"`, `"cancel"`, `"create"`,
|
||||||
|
`"resume"` appear in `DiscordWizardInteractionModule.cs`. It doesn't
|
||||||
|
check the renderer's output, so the bug is invisible to the test suite.
|
||||||
|
The same file's comment at line 69 documents the *expected* format as
|
||||||
|
`"btn:choice:Type:single"` — which would be the correct fix.
|
||||||
|
|
||||||
|
**How to fix.** Change `ButtonCustomId` in `DiscordWizardStep.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static string ButtonCustomId(string step, string value) =>
|
||||||
|
$"wizard:btn:choice:{step}:{value}";
|
||||||
|
```
|
||||||
|
|
||||||
|
This brings the renderer into alignment with the dispatcher's switch
|
||||||
|
case `"choice"` (line 209) and with the deliverable's table at line 83.
|
||||||
|
Re-verify with a manual click-through of every button on every step, or
|
||||||
|
add a parser-side test (see I-3 below).
|
||||||
|
|
||||||
|
### C-2. "Другое… ✏️" modal trigger buttons route to "default" instead of opening a modal
|
||||||
|
|
||||||
|
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:190, 206, 314`
|
||||||
|
**Read against:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs:207-226`
|
||||||
|
|
||||||
|
**What's wrong.** The renderer emits modal triggers as
|
||||||
|
`wizard:btn:modal:SystemFreeText` (the "Другое… ✏️" button on the System
|
||||||
|
step), `wizard:btn:modal:DurationFreeText` (Duration step), and
|
||||||
|
`wizard:btn:modal:PoolSystemDurationFreeText` (PoolSystemDuration step).
|
||||||
|
The dispatcher's button switch handles `"choice"`, `"back"`, `"cancel"`
|
||||||
|
but not `"modal"`. The user's click on "Другое…" hits the default branch
|
||||||
|
and returns "⚠️ Неизвестная кнопка" — no modal pops up, the wizard
|
||||||
|
doesn't advance. The open question in `deliverable.md:125-132` ("Modal
|
||||||
|
handler's free-text mapping is a hack") implicitly assumes these buttons
|
||||||
|
*work* in production, so the design intent is clear but the implementation
|
||||||
|
didn't deliver it.
|
||||||
|
|
||||||
|
**How to fix.** Add a `"modal"` case in the dispatcher's switch (between
|
||||||
|
`"create"` and the existing branches, mirroring the "create" / "resume"
|
||||||
|
special-case pattern):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (parts[1] == "modal" && parts.Length >= 3)
|
||||||
|
{
|
||||||
|
var modal = DiscordWizardStep.BuildModal(parts[2], draft.ChatId);
|
||||||
|
if (modal is not null)
|
||||||
|
{
|
||||||
|
await context.Interaction.SendResponseAsync(InteractionCallback.Modal(modal));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await AckWithErrorAsync(context.Interaction, "Модал недоступен");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This bypasses the wizard's state machine entirely (the user's intent is
|
||||||
|
"open a modal for free-text input", not "advance the wizard"). When the
|
||||||
|
user submits the modal, `HandleModalAsync` will run, which already knows
|
||||||
|
how to map `SystemFreeText` → `WizardStepNames.System` (line 453-462).
|
||||||
|
Add a click-through test for at least one of the three steps.
|
||||||
|
|
||||||
|
### C-3. `ex.Message` from `CreateSessionHandler` is shipped to the user's Discord
|
||||||
|
|
||||||
|
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:104`
|
||||||
|
|
||||||
|
**What's wrong.** On submit failure the submitter edits the draft
|
||||||
|
message with `$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}."`
|
||||||
|
and ships it to the user-visible draft embed. The exception originates
|
||||||
|
in `CreateSessionHandler` which talks to PostgreSQL via Dapper. Postgres
|
||||||
|
exception messages routinely include the constraint name, the conflicting
|
||||||
|
key value, and sometimes the full SQL text. Even the connection-string
|
||||||
|
DSN could leak if an `NpgsqlException` wraps a connection failure. A
|
||||||
|
malicious user who can submit many sessions can probe DB schema and
|
||||||
|
state by reading the error strings.
|
||||||
|
|
||||||
|
**How to fix.** Log the full `ex` to the server-side log (already done
|
||||||
|
on line 86) but show the user a generic error:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
$"💥 Ошибка при создании сессии. Попытка {payload.RetryCount}/{MaxRetries}. "
|
||||||
|
+ "Попробуйте повторить или обратитесь к администратору.",
|
||||||
|
RetryCancelActions(),
|
||||||
|
ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to preserve a per-error recovery hint (e.g. "Duplicate
|
||||||
|
title — pick a different name"), map known exception types to localized
|
||||||
|
strings; never embed the raw `ex.Message`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important findings
|
||||||
|
|
||||||
|
### I-1. `Owner`/`CoGm` permission lookup runs on every wizard invocation, no cache
|
||||||
|
|
||||||
|
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:85-87`
|
||||||
|
|
||||||
|
The slash command issues an `await DiscordPermissionLookup.LoadManagerUserIdsAsync(...)`
|
||||||
|
on every `/newsession-wizard` invocation. This is a 3-table join that
|
||||||
|
scales linearly with the number of clubs the user manages. With a 24-hour
|
||||||
|
draft lifetime and a single draft per owner, the same query repeats
|
||||||
|
frequently. Not critical for the v3.8.0 release, but a 30-second in-memory
|
||||||
|
cache would cut DB load noticeably during heavy wizard use. Same query
|
||||||
|
shape lives in `DiscordNewSessionHandler` already (per the file comment),
|
||||||
|
so a shared cache would benefit both.
|
||||||
|
|
||||||
|
### I-2. `_skip` sentinel bypasses the wizard's own validation
|
||||||
|
|
||||||
|
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:191, 207`
|
||||||
|
**Read against:** `src/GmRelay.Shared/Features/Sessions\CreateSession\Wizard\GameCreationWizard.cs:287-290`
|
||||||
|
|
||||||
|
`GameCreationWizard.ApplySystemChoice` matches `"_skip"` and accepts it
|
||||||
|
without further checks. The renderer emits `wizard:btn:System:_skip`.
|
||||||
|
This is correct *if* the choice-button wire format gets fixed (C-1); the
|
||||||
|
"`_skip`" string is hard-coded in the wizard and the renderer uses the
|
||||||
|
same constant. But there's no central constant — both files have their
|
||||||
|
own copies of the magic string. A future refactor that renames the
|
||||||
|
sentinel in one place will silently break the other. Suggest
|
||||||
|
`public const string SkipSentinel = "_skip"` on a shared class.
|
||||||
|
|
||||||
|
### I-3. Smoke tests are string-matching only — no behaviour coverage of the adapter
|
||||||
|
|
||||||
|
**File:** `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
|
||||||
|
|
||||||
|
All 12 smoke tests in this file are `Assert.Contains` against the
|
||||||
|
source text. They would all pass against a file full of `// choice`
|
||||||
|
comments and dead code. The test class header at line 8-17 acknowledges
|
||||||
|
this ("smoke gate"), and the broader Wizard test suite
|
||||||
|
(`tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/`) does
|
||||||
|
exercise the platform-neutral state machine — but the *adapter* (the
|
||||||
|
mapping from `ButtonInteractionContext` → `_wizard.HandleInteractionAsync`)
|
||||||
|
has zero behavioural coverage. C-1 and C-2 both bypass the entire
|
||||||
|
smoke-test surface.
|
||||||
|
|
||||||
|
Minimum bar to add: a parser-roundtrip test that takes the renderer's
|
||||||
|
output for each `RenderX()` step and feeds it through the dispatcher's
|
||||||
|
button handler to verify it doesn't fall into the default branch. Even
|
||||||
|
a hand-rolled `ButtonInteractionContext` fake (or a helper that mimics
|
||||||
|
the dispatcher's `args.Split(':', 4)` parser) would catch both bugs.
|
||||||
|
Cost: ~50 lines; payoff: catches the entire class of "renderer and
|
||||||
|
dispatcher disagree on the wire format" regressions.
|
||||||
|
|
||||||
|
### I-4. `AddComponentInteractions<TInteraction, TContext>` is called for Modal but the renderer relies on `Label → TextInput` layout
|
||||||
|
|
||||||
|
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardStep.cs:436-444`
|
||||||
|
**Read against:** `DiscordWizardInteractionModule.cs:440-451`
|
||||||
|
|
||||||
|
`BuildModal` always wraps a single `TextInput` in a `Label` (Discord's
|
||||||
|
`IModalComponentProperties` API requires labels). The dispatcher reads
|
||||||
|
`Components[0]` and assumes it is a `Label` (line 446). This is
|
||||||
|
consistent *today*, but if a future step needs two inputs in one
|
||||||
|
modal, the extraction logic needs to walk all components, not just
|
||||||
|
`[0]`. Document the constraint on the dispatcher's
|
||||||
|
`ExtractModalText` method ("current contract: exactly one Label,
|
||||||
|
one TextInput") and the renderer's `BuildModal` ("emits one
|
||||||
|
Label+TextInput, no exceptions").
|
||||||
|
|
||||||
|
### I-5. The 3-retry counter is bound to the in-memory `WizardPayload`, not the DB row
|
||||||
|
|
||||||
|
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs:86-89`
|
||||||
|
|
||||||
|
`payload.RetryCount += 1; SavePayload(draft, payload);` is called *before*
|
||||||
|
the `if (payload.RetryCount >= MaxRetries)` check. The counter is
|
||||||
|
serialized into `draft.PayloadJson` and re-loaded on the next click, so
|
||||||
|
the bound is correct across bot restarts. However, the in-memory
|
||||||
|
`draft` object is shared with the dispatcher's `_wizard` after
|
||||||
|
`HandleInteractionAsync` returns, and a future refactor that pulls the
|
||||||
|
payload from the DB instead of the in-memory copy could see a stale
|
||||||
|
count. Document the invariant: "RetryCount is read from the in-memory
|
||||||
|
payload after this line; do not re-load from DB before the comparison."
|
||||||
|
|
||||||
|
### I-6. `BuildResumeRow` re-uses the same customId suffix scheme as the in-wizard buttons
|
||||||
|
|
||||||
|
**File:** `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs:193-209`
|
||||||
|
|
||||||
|
`BuildResumeRow` emits three buttons:
|
||||||
|
- "▶️ Продолжить" → `wizard:btn:resume:continue` ✓ (works)
|
||||||
|
- "🔄 Заново" → `wizard:btn:resume:restart` ✓ (works)
|
||||||
|
- "❌ Отмена" → `wizard:btn:cancel:1` (uses `DiscordWizardStep.ButtonCustomId("cancel", "1")`)
|
||||||
|
|
||||||
|
The cancel button relies on the dispatcher's `parts[1] == "cancel"`
|
||||||
|
match. With C-1 fixed, this still works because cancel is in the
|
||||||
|
switch, not the new "choice" path. But the `ButtonCustomId` signature
|
||||||
|
will change semantics after C-1: it will become
|
||||||
|
`$"wizard:btn:choice:{step}:{value}"`. `BuildResumeRow` passing
|
||||||
|
`"cancel"` as the step will then produce `wizard:btn:choice:cancel:1`,
|
||||||
|
which the dispatcher's switch will not match (no `"choice"` in the
|
||||||
|
parts). Fix C-1 must update `BuildResumeRow` to emit
|
||||||
|
`wizard:btn:cancel:1` directly (not via `ButtonCustomId`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
|
||||||
|
- `DiscordWizardInteractionModule.cs:308, 357` — the select and modal
|
||||||
|
handlers split args with `Split(':', 2)` (max 2 parts), while the
|
||||||
|
button handler uses `Split(':', 4)` (max 4 parts). Inconsistent. The
|
||||||
|
net effect is correct (select has 2 segments, modal has 2, button has
|
||||||
|
2–4), but a comment explaining the max-count rationale would help.
|
||||||
|
- `DiscordWizardStep.cs:74` — `throw new InvalidOperationException` on
|
||||||
|
unknown step is fine for now, but a future maintainer adding a step
|
||||||
|
to `WizardStepNames` will get a runtime exception. A `switch` exhaustiveness
|
||||||
|
check (e.g. a private static assert in tests) would catch this at
|
||||||
|
build time.
|
||||||
|
- `DiscordPermissionLookup.cs:28-30` — `g.platform = 'Discord'` is
|
||||||
|
hard-coded in the SQL. A `g.platform = @Platform` parameter would
|
||||||
|
mirror the dispatcher's parameterized style and make the helper
|
||||||
|
reusable for any future platform. Not blocking.
|
||||||
|
- `DiscordWizardCommand.cs:72-81` — fetching the guild via REST inside
|
||||||
|
the slash command costs an extra round-trip. The
|
||||||
|
`resolvedPermissions` from the interaction already includes
|
||||||
|
`Administrator`; only the "guild owner" case needs the REST call.
|
||||||
|
Consider short-circuiting when `(resolvedPermissions & Administrator)
|
||||||
|
!= 0`.
|
||||||
|
- `DiscordWizardMessenger.cs:188-192` — hard-coded
|
||||||
|
`new Color(0x5865F2)` (Discord blurple). Extracting to a const
|
||||||
|
`WizardEmbedColor` would let the Web/Telegram versions use the same
|
||||||
|
brand color if they ever render wizards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration V032 sanity check
|
||||||
|
|
||||||
|
**File:** `src/GmRelay.Bot/Migrations/V032__wizard_drafts_platform.sql`
|
||||||
|
|
||||||
|
- Line 8-9 `ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram'`
|
||||||
|
— DEFAULT literal makes this O(1) on PostgreSQL ≥ 11. Safe on
|
||||||
|
existing rows.
|
||||||
|
- Lines 13-30 — `ALTER COLUMN ... TYPE TEXT USING ...::TEXT` on the
|
||||||
|
`chat_id`, `message_thread_id`, `draft_message_id`, and renamed
|
||||||
|
`owner_id` columns. All conversions are lossless
|
||||||
|
(BIGINT → decimal-string, INT → decimal-string). Safe.
|
||||||
|
- Line 26-27 `RENAME COLUMN owner_telegram_id TO owner_id` — the
|
||||||
|
rename happens mid-migration. Any DML hitting the table
|
||||||
|
concurrently that uses the old name will fail. For a bot that
|
||||||
|
processes both Telegram and Discord traffic, this is a brief
|
||||||
|
exclusivity lock. Consider splitting into two migrations
|
||||||
|
(rename + new index, then type change) so each lock is shorter.
|
||||||
|
Not blocking for v3.8.0, but document the brief lock window.
|
||||||
|
|
||||||
|
No DEFAULT-cascade issue, no NOT NULL on existing-row failure. The
|
||||||
|
deliverable's "Will this fail on existing rows?" question gets a
|
||||||
|
"no, but plan a maintenance window" answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture sanity (re-confirmed)
|
||||||
|
|
||||||
|
- `src/GmRelay.Shared/` — only references to Telegram.Bot/NetCord are
|
||||||
|
in doc comments warning the developer not to add them. csproj has
|
||||||
|
no Telegram.Bot or NetCord package references. `GameCreationWizard`,
|
||||||
|
`IWizardMessenger`, `WizardCallbackData`, `WizardStepLimits`,
|
||||||
|
`WizardStepNames`, `WizardPayload`, `WizardDraft` are all in exactly
|
||||||
|
one place (Shared). ✓
|
||||||
|
- `src/GmRelay.DiscordBot/Program.cs:87-102` — all 7 wizard services
|
||||||
|
registered as singleton. All 3 `AddComponentInteractions<...>`
|
||||||
|
calls present (Button, StringMenu, Modal). All 4 module/dispatcher
|
||||||
|
classes (`WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
|
||||||
|
`DiscordWizardStringMenuModule`, `DiscordWizardModalModule`)
|
||||||
|
registered. ✓
|
||||||
|
- AOT-safety: no `System.Reflection`, no `dynamic`, no
|
||||||
|
`Activator.CreateInstance`, no `Type.GetType` in the new Discord
|
||||||
|
or Shared code. ✓
|
||||||
|
- `DiscordPermissionLookup.cs:23-31` and
|
||||||
|
`DiscordWizardMessenger.cs:154-165` and the inline
|
||||||
|
`WizardClubLookup` in `DiscordWizardInteractionModule.cs:508-519`
|
||||||
|
all use parameterized queries (`@GuildId`, `@Platform`,
|
||||||
|
`@ExternalId`, `@OwnerId`). No SQL string interpolation. ✓
|
||||||
|
- `Program.cs:54` — `SecretRedactor.RedactConnectionString` on the
|
||||||
|
startup log. ✓
|
||||||
|
- `DiscordWizardCommand.cs:51-94` — DM invocations rejected
|
||||||
|
(`GuildId` null check), channel null-checked, member type-checked
|
||||||
|
via `as GuildInteractionUser`, owner/admin/DB-manager permission
|
||||||
|
check via `DiscordPermissionChecker.CanManageSchedule`. No NRE
|
||||||
|
on `Context.User`. ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Strong foundation: the platform-neutral refactor is well-executed, the
|
||||||
|
state machine has solid test coverage, the Discord adapter's DI graph
|
||||||
|
is clean, and security primitives (parameterized SQL, permission check,
|
||||||
|
secret redaction) are in place. But the Discord adapter's runtime path
|
||||||
|
is untested, and a single oversight in the renderer's button custom-id
|
||||||
|
format (missing the `choice:` segment) breaks every choice button in
|
||||||
|
the wizard at click time. The "Другое… ✏️" modal triggers are also
|
||||||
|
unrouted in the dispatcher, leaving the free-text input path
|
||||||
|
unreachable. The 3-attempt finalize loop works but leaks `ex.Message`
|
||||||
|
to the user. After fixing C-1 / C-2 / C-3, adding I-3 (behavioural
|
||||||
|
test of the adapter), and re-running the manual click-through checklist
|
||||||
|
(System → Duration → DateTime → Capacity → Visibility → Publish →
|
||||||
|
Confirm for Single; full pool flow), this branch is ready to merge.
|
||||||
File diff suppressed because it is too large
Load Diff
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,424 @@
|
|||||||
|
# Completed Game Portfolio - Design Spec
|
||||||
|
|
||||||
|
> Issue #108: feat: добавить портфолио прошедших игр в витрину мастера
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a public portfolio of completed tabletop adventures. A club owner or co-GM can group one or more completed sessions into an adventure card, publish it in selected GM profiles, optionally show it on a public club page, upload a cover image, and moderate player reviews. The existing `/showcase` catalog remains focused on recruitment for upcoming games.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product Decisions
|
||||||
|
|
||||||
|
- A portfolio item is an independent adventure entity, not a flag on one session.
|
||||||
|
- One adventure can reference multiple completed sessions from the same club.
|
||||||
|
- Reviews are submitted by authenticated players, not entered manually by a GM.
|
||||||
|
- A player can review an adventure after being actively registered as a non-GM participant for at least one linked completed session. Waitlisted players are not eligible.
|
||||||
|
- Each player can submit one review per adventure.
|
||||||
|
- A review is public only after the player explicitly consents to publication and a club owner or co-GM approves it.
|
||||||
|
- Public reviews show a display-name snapshot captured at submission time. They never expose platform IDs or account links.
|
||||||
|
- Adventure visibility in a public GM profile does not depend on club-page visibility.
|
||||||
|
- The public club page shows its portfolio block only when that club page is enabled.
|
||||||
|
- Club owners and co-GMs create, edit, publish, and moderate portfolio items. They select one or more GMs whose public profiles display the adventure.
|
||||||
|
- Creation is available from the club page and through a quick action from a completed session.
|
||||||
|
- Every published adventure has a dedicated public page at `/portfolio/{slug}`.
|
||||||
|
- Cover images are uploaded to application-managed storage. The first implementation uses a persistent Docker volume behind a replaceable storage interface so an S3-compatible implementation can be added later without changing pages or database tables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Add a bounded portfolio vertical slice to `GmRelay.Web` and a schema migration in `GmRelay.Bot`. The portfolio tables reference the existing `game_groups`, `players`, and `sessions` tables but do not change the recruitment catalog query or its future-session filters.
|
||||||
|
|
||||||
|
Keep portfolio persistence separate from the already large scheduling store. `IPortfolioStore` and `PortfolioService` own portfolio reads, writes, and review submission. `AuthorizedPortfolioService` wraps protected management operations and reuses `ISessionStore.IsGroupManagerAsync` plus the existing current-user identity model for owner/co-GM authorization. Public Razor pages inject `IPortfolioStore` directly for sanitized reads.
|
||||||
|
|
||||||
|
Cover storage is isolated behind `IPortfolioCoverStorage`. Pages and services work with generated storage keys and public paths rather than physical file locations. The local implementation stores files in a persistent mounted directory and serves them through a dedicated request path. A future S3 implementation can generate equivalent public paths or signed delivery URLs while preserving the same service contract and database fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Migration V029
|
||||||
|
|
||||||
|
Create `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`.
|
||||||
|
|
||||||
|
### `portfolio_games`
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | `UUID` | primary key, generated | Adventure identifier |
|
||||||
|
| `group_id` | `UUID` | not null, FK to `game_groups(id)` with cascade delete | Owning club |
|
||||||
|
| `public_slug` | `VARCHAR(160)` | unique case-insensitive when non-null | Public route segment |
|
||||||
|
| `title` | `VARCHAR(255)` | not null | Adventure title |
|
||||||
|
| `description` | `TEXT` | nullable for drafts | Public description |
|
||||||
|
| `cover_storage_key` | `TEXT` | nullable for drafts | Storage-provider-neutral cover key |
|
||||||
|
| `system` | `VARCHAR(50)` | nullable | Game system |
|
||||||
|
| `format` | `VARCHAR(20)` | nullable, checked against `Online`, `Offline`, `Hybrid` | Play format |
|
||||||
|
| `completed_at` | `TIMESTAMPTZ` | not null | Portfolio ordering date |
|
||||||
|
| `is_public` | `BOOLEAN` | not null, default false | Public visibility |
|
||||||
|
| `published_at` | `TIMESTAMPTZ` | nullable | First publication timestamp |
|
||||||
|
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||||
|
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||||
|
|
||||||
|
Constraints and indexes:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CHECK (NOT is_public OR (
|
||||||
|
public_slug IS NOT NULL
|
||||||
|
AND description IS NOT NULL
|
||||||
|
AND cover_storage_key IS NOT NULL
|
||||||
|
AND published_at IS NOT NULL
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
- Unique index on `lower(public_slug)` when `public_slug IS NOT NULL`.
|
||||||
|
- Index on `(group_id, completed_at DESC)`.
|
||||||
|
- Partial public index on `(completed_at DESC)` where `is_public = true`.
|
||||||
|
|
||||||
|
Application validation additionally requires at least one linked session, every linked session to be completed with `scheduled_at < now()`, and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links.
|
||||||
|
|
||||||
|
Immediate statement triggers acquire one transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`, before any invariant-affecting rows are changed: publication transitions and deletes, required-link edits, session deletes and scheduled-date changes, and parent deletes that can cascade into required links. Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Portfolio and schedule mutations are low volume, so this intentionally global lock establishes one advisory-lock then row-lock protocol, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card, card/advisory, and session/advisory deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations.
|
||||||
|
|
||||||
|
A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. It rejects final-future reschedules outside `READ COMMITTED` with `0A000`, because the unpublish pass requires fresh statement snapshots. Under `READ COMMITTED`, it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then re-acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Session mutation paths use advisory-lock then `sessions` then linked `portfolio_games`; normal session-deletion handlers explicitly acquire the mutation lock, lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless.
|
||||||
|
|
||||||
|
### `portfolio_game_sessions`
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
|
||||||
|
| `session_id` | `UUID` | not null, unique, FK to `sessions(id)` with cascade delete | Linked completed session |
|
||||||
|
|
||||||
|
Primary key: `(portfolio_game_id, session_id)`.
|
||||||
|
|
||||||
|
The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. The deferred database guard enforces the completed-session condition for every linked session before a public card can commit. A session belongs to at most one portfolio adventure.
|
||||||
|
|
||||||
|
### `portfolio_game_masters`
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
|
||||||
|
| `player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Displayed GM |
|
||||||
|
|
||||||
|
Primary key: `(portfolio_game_id, player_id)`.
|
||||||
|
|
||||||
|
Add an index on `(player_id, portfolio_game_id)` for public GM profile reads.
|
||||||
|
|
||||||
|
### `portfolio_game_reviews`
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | `UUID` | primary key, generated | Review identifier |
|
||||||
|
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
|
||||||
|
| `author_player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Private author reference |
|
||||||
|
| `author_display_name` | `VARCHAR(255)` | not null | Public snapshot |
|
||||||
|
| `body` | `TEXT` | not null | Review text |
|
||||||
|
| `publication_consent_at` | `TIMESTAMPTZ` | not null | Player consent timestamp |
|
||||||
|
| `moderation_status` | `VARCHAR(20)` | not null, default `Pending`, checked | Moderation state |
|
||||||
|
| `moderated_by_player_id` | `UUID` | nullable, FK to `players(id)` with set null on delete | Private moderator reference |
|
||||||
|
| `moderated_at` | `TIMESTAMPTZ` | nullable | Moderation timestamp |
|
||||||
|
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||||
|
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
|
||||||
|
|
||||||
|
Constraints and indexes:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))
|
||||||
|
UNIQUE (portfolio_game_id, author_player_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Author lookup index `ix_portfolio_game_reviews_author` on `(author_player_id)`.
|
||||||
|
- Partial moderator lookup index `ix_portfolio_game_reviews_moderator` on `(moderated_by_player_id)` where `moderated_by_player_id IS NOT NULL`.
|
||||||
|
- Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`.
|
||||||
|
- Partial moderation index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Pending'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cover Storage
|
||||||
|
|
||||||
|
### Contract
|
||||||
|
|
||||||
|
Add a small storage abstraction:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IPortfolioCoverStorage
|
||||||
|
{
|
||||||
|
Task<PortfolioCoverUploadResult> SaveAsync(
|
||||||
|
Stream content,
|
||||||
|
string contentType,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task DeleteIfExistsAsync(
|
||||||
|
string storageKey,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
string GetPublicPath(string storageKey);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PortfolioCoverUploadResult` carries the generated storage key and normalized content type.
|
||||||
|
|
||||||
|
### Local Implementation
|
||||||
|
|
||||||
|
- Store covers below a configured `PortfolioCovers:StoragePath`.
|
||||||
|
- Mount that path from a dedicated Docker volume, `portfolio_covers`.
|
||||||
|
- Serve files through a dedicated `/portfolio-covers/{storageKey}` route.
|
||||||
|
- Generate random names. Never use the uploaded filename as the storage key.
|
||||||
|
- Accept `image/jpeg`, `image/png`, and `image/webp`.
|
||||||
|
- Limit uploads to 5 MiB.
|
||||||
|
- Validate file signatures server-side before writing the final file.
|
||||||
|
- Write to a temporary file, validate, then atomically move into place.
|
||||||
|
- On successful replacement, delete the old file.
|
||||||
|
- On database failure after upload, delete the newly uploaded file.
|
||||||
|
- Deleting an adventure deletes its current cover after successful database deletion.
|
||||||
|
|
||||||
|
The storage key remains provider-neutral. A future S3-compatible implementation can replace the local service registration and use the same stored key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Contracts
|
||||||
|
|
||||||
|
Add sanitized DTOs to `IPortfolioStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links.
|
||||||
|
|
||||||
|
Representative contracts:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record PublicPortfolioGame(
|
||||||
|
string Slug,
|
||||||
|
string Title,
|
||||||
|
string Description,
|
||||||
|
string CoverPath,
|
||||||
|
string? System,
|
||||||
|
string? Format,
|
||||||
|
DateTime CompletedAt,
|
||||||
|
string? ClubName,
|
||||||
|
string? ClubSlug,
|
||||||
|
IReadOnlyList<PublicPortfolioMaster> Masters,
|
||||||
|
IReadOnlyList<PublicPortfolioReview> Reviews);
|
||||||
|
|
||||||
|
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
|
||||||
|
|
||||||
|
public sealed record PublicPortfolioReview(
|
||||||
|
string AuthorDisplayName,
|
||||||
|
string Body,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
Protected DTOs may carry IDs needed for editing and moderation.
|
||||||
|
|
||||||
|
### Public Reads
|
||||||
|
|
||||||
|
- Load one public adventure by slug for `/portfolio/{slug}`.
|
||||||
|
- Load public adventures for a public GM profile regardless of club-page visibility.
|
||||||
|
- Load public adventures for a public club page only when the club page is enabled.
|
||||||
|
- Return only reviews with explicit consent and `Approved` moderation state.
|
||||||
|
|
||||||
|
### Protected Management
|
||||||
|
|
||||||
|
Through `AuthorizedPortfolioService`:
|
||||||
|
|
||||||
|
- Load draft and published adventure cards for a managed club.
|
||||||
|
- Load eligible completed sessions for a managed club.
|
||||||
|
- Create a draft, optionally preselecting one completed session from the quick action.
|
||||||
|
- Update title, slug, description, system, format, linked sessions, and displayed GMs.
|
||||||
|
- Upload and replace the cover.
|
||||||
|
- Publish or unpublish a card.
|
||||||
|
- Load pending and historical reviews for moderation.
|
||||||
|
- Approve, reject, or hide a review.
|
||||||
|
|
||||||
|
All management operations require the current user to be an owner or co-GM of the owning club.
|
||||||
|
|
||||||
|
### Review Submission
|
||||||
|
|
||||||
|
An authenticated user can submit a review from `/portfolio/{slug}` only when:
|
||||||
|
|
||||||
|
- The adventure is public.
|
||||||
|
- The user explicitly checks publication consent.
|
||||||
|
- The user is registered in `session_participants` as a non-GM participant with `registration_status = 'Active'` for at least one linked session.
|
||||||
|
- The linked session is in the past.
|
||||||
|
- The user has not submitted a review for this adventure before.
|
||||||
|
|
||||||
|
The created review starts in `Pending`. The public page does not display it until moderation changes the status to `Approved`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Interface
|
||||||
|
|
||||||
|
### Protected Club Page
|
||||||
|
|
||||||
|
Extend `GroupDetails.razor` with a completed-adventures section:
|
||||||
|
|
||||||
|
- List draft and published portfolio cards.
|
||||||
|
- Show title, publication state, linked-session count, displayed-GM count, and review moderation count.
|
||||||
|
- Provide a create action, edit links, and a link to the club's completed-session list.
|
||||||
|
|
||||||
|
### Completed Session Quick Action
|
||||||
|
|
||||||
|
Add a protected `/group/{groupId}/completed` page that lists past sessions for a managed club. Extend that page and session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected.
|
||||||
|
|
||||||
|
### Adventure Editor
|
||||||
|
|
||||||
|
Add a protected editor page:
|
||||||
|
|
||||||
|
- Title and public slug.
|
||||||
|
- Description.
|
||||||
|
- System and format.
|
||||||
|
- Multi-select of completed sessions from the same club.
|
||||||
|
- Multi-select of displayed GMs.
|
||||||
|
- Cover upload and replacement.
|
||||||
|
- Draft save and publish/unpublish actions.
|
||||||
|
- Review moderation list with approve, reject, and hide actions.
|
||||||
|
|
||||||
|
The editor surfaces validation errors without publishing partial data.
|
||||||
|
|
||||||
|
### Public GM Profile
|
||||||
|
|
||||||
|
Extend `/gm/{slug}` with a "Проведённые приключения" portfolio section. Cards show cover, title, completion date, system, format, and a link to `/portfolio/{slug}`. This list is independent of club-page visibility.
|
||||||
|
|
||||||
|
### Public Club Page
|
||||||
|
|
||||||
|
Extend `/club/{slug}` with the same compact cards when the public club page is enabled.
|
||||||
|
|
||||||
|
### Public Adventure Page
|
||||||
|
|
||||||
|
Add `/portfolio/{slug}`:
|
||||||
|
|
||||||
|
- Cover hero.
|
||||||
|
- Title, description, completion date, system, and format.
|
||||||
|
- Optional public club link.
|
||||||
|
- Public links to selected GM profiles.
|
||||||
|
- Approved reviews with display-name snapshots.
|
||||||
|
- For an eligible authenticated player without an existing review: review form with text area and required publication-consent checkbox.
|
||||||
|
- For an authenticated ineligible player or a player who already submitted: a short non-sensitive status message.
|
||||||
|
- For an anonymous visitor: a sign-in prompt instead of the form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy And Security
|
||||||
|
|
||||||
|
- Public DTOs and rendered HTML never expose platform identifiers, player IDs, moderator IDs, linked session IDs, join links, or physical storage paths.
|
||||||
|
- Cover upload validation uses content signatures, not only the browser-provided MIME type or filename.
|
||||||
|
- Random storage keys prevent filename guessing and path traversal.
|
||||||
|
- Review text is rendered as encoded text through normal Razor rendering.
|
||||||
|
- Authorization is checked in the service layer for every management operation.
|
||||||
|
- Eligibility is checked in the database-backed service when submitting a review; hiding the form is not treated as authorization.
|
||||||
|
- The `/showcase` query keeps its current future-session condition and does not include completed adventures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker And Configuration
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
discord:
|
||||||
|
depends_on:
|
||||||
|
bot:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
web:
|
||||||
|
depends_on:
|
||||||
|
bot:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
|
||||||
|
volumes:
|
||||||
|
- portfolio_covers:/app/portfolio-covers
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
portfolio_covers:
|
||||||
|
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
|
||||||
|
```
|
||||||
|
|
||||||
|
Development configuration uses a local directory under the application content root or an explicitly configured path.
|
||||||
|
|
||||||
|
The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user.
|
||||||
|
|
||||||
|
The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate with database resource name `gmrelaydb`, matching application `ConnectionStrings:gmrelaydb`; it explicitly exposes the bot project resource's non-proxied port `8081` endpoint, attaches `.WithHttpHealthCheck("/health", endpointName: "health")`, and makes its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
- `README.md` with public portfolio capability and local cover-storage configuration.
|
||||||
|
- `docs/c4-system-context.md` with the portfolio slice and persistent cover volume.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Follow TDD for production changes.
|
||||||
|
|
||||||
|
### Schema And Contracts
|
||||||
|
|
||||||
|
- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, immediate statement-level mutation locks, completed-session validator, deferred future-reschedule unpublish trigger, advisory-lock then session-row deletion locks, and the AppHost HTTP health gate.
|
||||||
|
- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit mutation-lock then session-lock then unpublish then session deletion, delete/reschedule mutation-gate ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, required-link insertion and final-future reschedule mutation locks before rows, opposing-order batch future reschedules serialized before session rows, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, and parent/card cascade deletion.
|
||||||
|
- Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent.
|
||||||
|
- Existing showcase tests continue to assert the future-session catalog boundary.
|
||||||
|
|
||||||
|
### Authorization And Eligibility
|
||||||
|
|
||||||
|
- Owner and co-GM can manage a club adventure.
|
||||||
|
- A manager of another club cannot manage it.
|
||||||
|
- Only registered players from linked past sessions can submit.
|
||||||
|
- A registered player can submit only once.
|
||||||
|
- Consent is required.
|
||||||
|
- A new review is pending and not public.
|
||||||
|
- Only approved reviews are returned publicly.
|
||||||
|
|
||||||
|
### Cover Storage
|
||||||
|
|
||||||
|
- Accept valid JPEG, PNG, and WebP signatures.
|
||||||
|
- Reject unsupported types, mismatched signatures, oversized files, and unsafe names.
|
||||||
|
- Replacement deletes the old file only after the new file is stored.
|
||||||
|
- Cleanup removes a newly uploaded file when persistence fails.
|
||||||
|
|
||||||
|
### UI Source Contracts
|
||||||
|
|
||||||
|
- Protected club and session-history pages expose management entry points.
|
||||||
|
- Public GM and club pages render compact portfolio sections.
|
||||||
|
- The public adventure page renders approved reviews and the conditional review form.
|
||||||
|
- CSS defines responsive portfolio cards, cover hero, editor layout, and review states.
|
||||||
|
|
||||||
|
### Regression
|
||||||
|
|
||||||
|
- Run the full test suite.
|
||||||
|
- Run `dotnet build`.
|
||||||
|
- Run `dotnet format --verify-no-changes`.
|
||||||
|
- Visually inspect the protected editor and public portfolio pages in the browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version Bump
|
||||||
|
|
||||||
|
Issue label: `type:feature` -> minor bump.
|
||||||
|
|
||||||
|
Current: `3.5.1` -> Next: `3.6.0`.
|
||||||
|
|
||||||
|
Synchronize:
|
||||||
|
|
||||||
|
- `Directory.Build.props`
|
||||||
|
- `compose.yaml` (`bot`, `discord`, and `web` image tags)
|
||||||
|
- `.gitea/workflows/deploy.yml` (`VERSION`)
|
||||||
|
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria Mapping
|
||||||
|
|
||||||
|
- [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description.
|
||||||
|
- [ ] A portfolio adventure can group one or more completed sessions from the same club.
|
||||||
|
- [ ] A public portfolio adventure automatically becomes private if any linked completed session is rescheduled into the future, preserving its first-publication timestamp.
|
||||||
|
- [ ] Selected public GM profiles show portfolio cards independently of club-page visibility.
|
||||||
|
- [ ] A public club page shows portfolio cards when enabled.
|
||||||
|
- [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews.
|
||||||
|
- [ ] A registered participant of a linked completed session can submit one review with explicit publication consent.
|
||||||
|
- [ ] Reviews remain non-public until owner/co-GM moderation approves them.
|
||||||
|
- [ ] Public DTOs and HTML do not expose private identifiers.
|
||||||
|
- [ ] Uploaded covers survive container replacement through a persistent Docker volume.
|
||||||
|
- [ ] Storage is isolated behind a replaceable interface for a later S3-compatible implementation.
|
||||||
|
- [ ] The existing `/showcase` catalog remains focused on upcoming recruitment games.
|
||||||
@@ -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,11 +2,15 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\GmRelay.Bot\GmRelay.Bot.csproj" />
|
<ProjectReference Include="..\GmRelay.Bot\GmRelay.Bot.csproj" />
|
||||||
|
<ProjectReference Include="..\GmRelay.DiscordBot\GmRelay.DiscordBot.csproj" />
|
||||||
<ProjectReference Include="..\GmRelay.Web\GmRelay.Web.csproj" />
|
<ProjectReference Include="..\GmRelay.Web\GmRelay.Web.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.1" />
|
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.1" />
|
||||||
|
<!-- Overrides transitive vulnerable MessagePack 2.5.192 pulled by Aspire.Hosting.PostgreSQL.
|
||||||
|
See GHSA-hv8m-jj95-wg3x / CVE-2026-48109. -->
|
||||||
|
<PackageReference Include="MessagePack" Version="2.5.301" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@@ -2,14 +2,22 @@ var builder = DistributedApplication.CreateBuilder(args);
|
|||||||
|
|
||||||
var postgres = builder.AddPostgres("postgres")
|
var postgres = builder.AddPostgres("postgres")
|
||||||
.WithPgAdmin()
|
.WithPgAdmin()
|
||||||
.AddDatabase("gmrelay-db");
|
.AddDatabase("gmrelaydb");
|
||||||
|
|
||||||
builder.AddProject<Projects.GmRelay_Bot>("bot")
|
var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
|
||||||
.WithReference(postgres)
|
.WithReference(postgres)
|
||||||
.WaitFor(postgres);
|
.WaitFor(postgres)
|
||||||
|
.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)
|
||||||
|
.WithHttpHealthCheck("/health", endpointName: "health");
|
||||||
|
|
||||||
|
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
|
||||||
|
.WithReference(postgres)
|
||||||
|
.WaitFor(postgres)
|
||||||
|
.WaitFor(bot);
|
||||||
|
|
||||||
builder.AddProject<Projects.GmRelay_Web>("web")
|
builder.AddProject<Projects.GmRelay_Web>("web")
|
||||||
.WithReference(postgres)
|
.WithReference(postgres)
|
||||||
.WaitFor(postgres);
|
.WaitFor(postgres)
|
||||||
|
.WaitFor(bot);
|
||||||
|
|
||||||
builder.Build().Run();
|
builder.Build().Run();
|
||||||
|
|||||||
@@ -0,0 +1,688 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MessagePack": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[2.5.301, )",
|
||||||
|
"resolved": "2.5.301",
|
||||||
|
"contentHash": "WUnJgmYc06ngIxZxLe9sa0P6rOTyOZIQn8SuDvJSjyMn7e8/AdlNAdt81WPUhWKeQ7hDkgxKU1vTrJqX/4L79A==",
|
||||||
|
"dependencies": {
|
||||||
|
"MessagePack.Annotations": "2.5.301",
|
||||||
|
"Microsoft.NET.StringTools": "17.6.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.Annotations": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.5.301",
|
||||||
|
"contentHash": "3PyBiSeKTfvtyzUv3+9eXGIw7vBBZ0GAc4k3+RVT0tz2vKv3l0pviiA2b6DrmHyDvj1Au8lSVDDw/wKPMxUQ4A=="
|
||||||
|
},
|
||||||
|
"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,18 @@ 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
|
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
|
||||||
|
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
wget libgssapi-krb5-2 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Копируем только AOT-результаты из билда
|
# Копируем только AOT-результаты из билда
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
USER $APP_UID
|
||||||
|
|
||||||
# Запуск скомпилированного AOT бинарного файла напрямую
|
# Запуск скомпилированного AOT бинарного файла напрямую
|
||||||
ENTRYPOINT ["./GmRelay.Bot"]
|
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,149 +0,0 @@
|
|||||||
using Dapper;
|
|
||||||
using GmRelay.Bot.Features.Notifications;
|
|
||||||
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,
|
|
||||||
string NotificationMode);
|
|
||||||
|
|
||||||
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,
|
|
||||||
DirectSessionNotificationSender directSender,
|
|
||||||
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,
|
|
||||||
s.notification_mode AS NotificationMode
|
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
|
||||||
if (mode.ShouldSendDirectMessages())
|
|
||||||
{
|
|
||||||
var directText = $"""
|
|
||||||
🎲 <b>Подтвердите участие в игре</b>
|
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
|
||||||
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
|
|
||||||
|
|
||||||
Ответьте кнопкой в групповом сообщении расписания.
|
|
||||||
""";
|
|
||||||
|
|
||||||
await directSender.SendAsync(
|
|
||||||
participants.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
|
|
||||||
directText,
|
|
||||||
"confirmation",
|
|
||||||
sessionId,
|
|
||||||
ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
using Telegram.Bot;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using Telegram.Bot.Types.Enums;
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Notifications;
|
namespace GmRelay.Bot.Features.Notifications;
|
||||||
|
|
||||||
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
|
public sealed record DirectNotificationRecipient(long TelegramId, string DisplayName);
|
||||||
|
|
||||||
public sealed class DirectSessionNotificationSender(
|
public sealed class DirectSessionNotificationSender(
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
ILogger<DirectSessionNotificationSender> logger)
|
ILogger<DirectSessionNotificationSender> logger)
|
||||||
{
|
{
|
||||||
public async Task SendAsync(
|
public async Task SendAsync(
|
||||||
@@ -20,11 +20,11 @@ public sealed class DirectSessionNotificationSender(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await messenger.SendPrivateMessageAsync(
|
||||||
chatId: recipient.TelegramId,
|
new PlatformPrivateMessage(
|
||||||
text: htmlText,
|
TelegramPlatformIds.User(recipient.TelegramId, recipient.DisplayName),
|
||||||
parseMode: ParseMode.Html,
|
htmlText),
|
||||||
cancellationToken: ct);
|
ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
using Dapper;
|
|
||||||
using GmRelay.Bot.Features.Notifications;
|
|
||||||
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,
|
|
||||||
string NotificationMode);
|
|
||||||
|
|
||||||
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,
|
|
||||||
DirectSessionNotificationSender directSender,
|
|
||||||
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,
|
|
||||||
s.notification_mode AS NotificationMode
|
|
||||||
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 });
|
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
|
||||||
if (mode.ShouldSendDirectMessages())
|
|
||||||
{
|
|
||||||
var directText = $"""
|
|
||||||
🎮 <b>Игра начинается через 5 минут</b>
|
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
|
|
||||||
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
|
|
||||||
""";
|
|
||||||
|
|
||||||
await directSender.SendAsync(
|
|
||||||
players.Select(p => new DirectNotificationRecipient(p.TelegramId, p.DisplayName)),
|
|
||||||
directText,
|
|
||||||
"join-link",
|
|
||||||
sessionId,
|
|
||||||
ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation(
|
|
||||||
"Join link sent for session {SessionId} ({Title}), message_id={MessageId}",
|
|
||||||
sessionId, session.Title, message.MessageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Bot.Features.Notifications;
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using Telegram.Bot.Types;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
@@ -13,14 +13,15 @@ public sealed record CancelSessionCommand(
|
|||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
string CallbackQueryId,
|
string CallbackQueryId,
|
||||||
long ChatId,
|
long ChatId,
|
||||||
|
int? MessageThreadId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// DTOs for AOT compilation
|
// DTOs for AOT compilation
|
||||||
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, long GmId, string NotificationMode);
|
internal sealed record CancelSessionInfoDto(string Title, Guid BatchId, int? BatchMessageId, bool CanManage, string NotificationMode);
|
||||||
|
|
||||||
public sealed class CancelSessionHandler(
|
public sealed class CancelSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<CancelSessionHandler> logger)
|
ILogger<CancelSessionHandler> logger)
|
||||||
{
|
{
|
||||||
@@ -28,24 +29,36 @@ public sealed class CancelSessionHandler(
|
|||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
// 1. Проверяем, что запрос делает ГМ данной сессии
|
// 1. Проверяем, что запрос делает управляющий данной группы.
|
||||||
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
|
var session = await connection.QuerySingleOrDefaultAsync<CancelSessionInfoDto>(
|
||||||
@"SELECT s.title as Title, s.batch_id as BatchId, g.gm_telegram_id as GmId, s.notification_mode as NotificationMode
|
"""
|
||||||
FROM sessions s
|
SELECT s.title AS Title,
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
s.batch_id AS BatchId,
|
||||||
WHERE s.id = @SessionId",
|
s.batch_message_id AS BatchMessageId,
|
||||||
new { command.SessionId }, transaction);
|
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)
|
if (session == null)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +70,17 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
// 3. Загружаем весь батч для перерисовки
|
// 3. Загружаем весь батч для перерисовки
|
||||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
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,
|
||||||
|
format as Format,
|
||||||
|
location_address as LocationAddress,
|
||||||
|
description as Description,
|
||||||
|
system as System,
|
||||||
|
duration_minutes as DurationMinutes,
|
||||||
|
is_one_shot as IsOneShot
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at",
|
ORDER BY scheduled_at",
|
||||||
@@ -77,7 +100,7 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
|
||||||
"""
|
"""
|
||||||
SELECT p.telegram_id AS TelegramId,
|
SELECT p.external_user_id::BIGINT AS TelegramId,
|
||||||
p.display_name AS DisplayName
|
p.display_name AS DisplayName
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
@@ -91,22 +114,25 @@ public sealed class CancelSessionHandler(
|
|||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
// 4. Перерисовываем сообщение
|
// 4. Перерисовываем сообщение
|
||||||
var renderResult = SessionBatchRenderer.Render(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions.ToList(), batchParticipants.ToList());
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.EditMessageText(
|
var messageId = session.BatchMessageId ?? command.MessageId;
|
||||||
chatId: command.ChatId,
|
await messenger.UpdateScheduleAsync(
|
||||||
messageId: command.MessageId,
|
new PlatformScheduleMessage(
|
||||||
text: renderResult.Text,
|
TelegramPlatformIds.Group(command.ChatId, command.MessageThreadId),
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
view,
|
||||||
replyMarkup: renderResult.Markup,
|
TelegramPlatformIds.Message(command.ChatId, command.MessageThreadId, messageId)),
|
||||||
cancellationToken: ct);
|
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);
|
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
@@ -122,7 +148,10 @@ public sealed class CancelSessionHandler(
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to update batch message after cancelling session {SessionId}", command.SessionId);
|
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,160 +1,358 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
|
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
public sealed class CreateSessionHandler(
|
/// <summary>
|
||||||
NpgsqlDataSource dataSource,
|
/// Telegram-side entry point for the wizard-driven session creation
|
||||||
ITelegramBotClient botClient,
|
/// flow. Talks to the shared wizard through <see cref="IWizardMessenger"/>
|
||||||
ILogger<CreateSessionHandler> logger)
|
/// and the platform-neutral <see cref="WizardDraft"/>. Keeps the
|
||||||
|
/// platform glue (mapping <c>Message</c> to draft fields, rendering
|
||||||
|
/// error keyboards, etc.) local to <c>GmRelay.Bot</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateSessionHandler
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
private const int MaxRetries = 3;
|
||||||
|
private const string PlatformName = "Telegram";
|
||||||
|
|
||||||
|
private readonly IWizardDraftRepository _drafts;
|
||||||
|
private readonly SharedCreateSessionHandler _shared;
|
||||||
|
private readonly IWizardMessenger _messenger;
|
||||||
|
private readonly ILogger<CreateSessionHandler> _log;
|
||||||
|
private readonly IPlatformMessenger? _platformMessenger;
|
||||||
|
private readonly NpgsqlDataSource? _dataSource;
|
||||||
|
|
||||||
|
public CreateSessionHandler(
|
||||||
|
IWizardDraftRepository drafts,
|
||||||
|
SharedCreateSessionHandler shared,
|
||||||
|
IWizardMessenger messenger,
|
||||||
|
ILogger<CreateSessionHandler> log,
|
||||||
|
IPlatformMessenger? platformMessenger = null,
|
||||||
|
NpgsqlDataSource? dataSource = null)
|
||||||
{
|
{
|
||||||
var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
|
_drafts = drafts;
|
||||||
|
_shared = shared;
|
||||||
|
_messenger = messenger;
|
||||||
|
_log = log;
|
||||||
|
_platformMessenger = platformMessenger;
|
||||||
|
_dataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var timeInput in parseResult.PastTimeInputs)
|
/// <summary>
|
||||||
|
/// Entry point for <c>/newsession</c>. If a non-expired draft
|
||||||
|
/// already exists for this owner, returns <c>null</c> so the caller
|
||||||
|
/// can render a "Continue / Start over / Cancel" menu.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||||
|
var existing = await _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||||||
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
return null;
|
||||||
message.Chat.Id,
|
|
||||||
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
var draft = new WizardDraft
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
Id = Guid.NewGuid(),
|
||||||
message.Chat.Id,
|
ChatId = message.Chat.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
MessageThreadId = message.MessageThreadId?.ToString(CultureInfo.InvariantCulture),
|
||||||
cancellationToken: cancellationToken);
|
OwnerId = ownerId,
|
||||||
}
|
Platform = PlatformName,
|
||||||
|
Step = WizardStepNames.Type,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||||
|
};
|
||||||
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
|
|
||||||
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
|
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
|
||||||
{
|
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||||
await botClient.SendMessage(
|
draft.DraftMessageId = msgId;
|
||||||
message.Chat.Id,
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
cancellationToken: cancellationToken);
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parseResult.IsValid)
|
/// <summary>
|
||||||
|
/// Resume an existing draft — returns the draft row so the caller
|
||||||
|
/// can re-render the resume/reset menu.
|
||||||
|
/// </summary>
|
||||||
|
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ownerId = (message.From?.Id ?? 0).ToString(CultureInfo.InvariantCulture);
|
||||||
|
return _drafts.GetActiveAsync(PlatformName, ownerId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finalize: build shared command(s), call the shared handler, edit
|
||||||
|
/// the wizard message. On failure, retry up to <see cref="MaxRetries"/>
|
||||||
|
/// times before deleting the draft.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var payload = LoadPayload(draft);
|
||||||
|
if (!IsComplete(payload, out var missing))
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await _messenger.EditDraftMessageAsync(
|
||||||
chatId: message.Chat.Id,
|
draft, $"❌ Не заполнены поля: {missing}", Array.Empty<WizardAction>(), ct);
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = parseResult.Title!;
|
var commands = BuildCommands(draft, payload);
|
||||||
var link = parseResult.Link!;
|
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
|
||||||
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";
|
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
|
||||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await connection.ExecuteAsync(
|
foreach (var cmd in commands)
|
||||||
"""
|
|
||||||
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(
|
var result = await _shared.HandleAsync(cmd, ct);
|
||||||
chatId: chatId,
|
if (!result.Success)
|
||||||
name: $"🎲 Игры: {title}",
|
{
|
||||||
cancellationToken: cancellationToken);
|
await _messenger.EditDraftMessageAsync(
|
||||||
messageThreadId = topic.MessageThreadId;
|
draft,
|
||||||
}
|
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var batchId = Guid.NewGuid();
|
created.Add((cmd, result));
|
||||||
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);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Ошибка при создании сессии");
|
_log.LogError(ex, "SubmitDraftAsync failed for draft {DraftId}", draft.Id);
|
||||||
await transaction.RollbackAsync(cancellationToken);
|
payload.RetryCount += 1;
|
||||||
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
|
SavePayload(draft, payload);
|
||||||
|
if (payload.RetryCount >= MaxRetries)
|
||||||
|
{
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||||||
|
RetryCancelActions(),
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var totalSessions = created.Sum(c => c.Command.ScheduledTimes.Count);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var item in created)
|
||||||
|
{
|
||||||
|
await PublishCreatedSessionAsync(item.Command, item.Result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "SubmitDraftAsync created draft {DraftId} but failed to publish schedule", draft.Id);
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}, но не удалось опубликовать сообщение для записи: {ex.Message}",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PublishCreatedSessionAsync(CreateSessionCommand command, CreateSessionResult result, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_platformMessenger is null || _dataSource is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Session publication dependencies are not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.View is null || result.BatchId is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Created session result does not contain publication data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var group = command.Group;
|
||||||
|
var topicCreatedByBot = false;
|
||||||
|
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
|
||||||
|
{
|
||||||
|
var thread = await _platformMessenger.CreateThreadAsync(group, command.Title, ct);
|
||||||
|
group = group with { ExternalThreadId = thread.ExternalThreadId };
|
||||||
|
topicCreatedByBot = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduleMessage = await _platformMessenger.SendScheduleAsync(
|
||||||
|
new PlatformScheduleMessage(group, result.View, ExistingMessage: null, command.ImageReference),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET thread_id = @ThreadId,
|
||||||
|
batch_message_id = @BatchMessageId,
|
||||||
|
topic_created_by_bot = @TopicCreatedByBot,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE batch_id = @BatchId
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
result.BatchId,
|
||||||
|
ThreadId = ParseNullableInt(group.ExternalThreadId),
|
||||||
|
BatchMessageId = ParseInt(scheduleMessage.ExternalMessageId),
|
||||||
|
TopicCreatedByBot = topicCreatedByBot
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseInt(string value) =>
|
||||||
|
int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static int? ParseNullableInt(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? null
|
||||||
|
: int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
// ── Build shared commands ────────────────────────────────────────
|
||||||
|
// The shared handler creates one session per scheduled time in a
|
||||||
|
// single transaction and assigns the same batch_id to all of them.
|
||||||
|
// A wizard pool therefore produces ONE command with N times; a
|
||||||
|
// single-game wizard produces ONE command with one time.
|
||||||
|
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
|
||||||
|
{
|
||||||
|
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
|
||||||
|
{
|
||||||
|
return new List<CreateSessionCommand>
|
||||||
|
{
|
||||||
|
BuildCommand(
|
||||||
|
draft,
|
||||||
|
p,
|
||||||
|
pool.Slots.Select(s => s.ScheduledAt).ToList(),
|
||||||
|
MaxPlayersForPool(pool),
|
||||||
|
isOneShot: false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return new List<CreateSessionCommand>
|
||||||
|
{
|
||||||
|
BuildCommand(
|
||||||
|
draft,
|
||||||
|
p,
|
||||||
|
new[] { p.Single?.ScheduledAt ?? default },
|
||||||
|
p.Single?.MaxPlayers,
|
||||||
|
isOneShot: true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||||
|
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||||
|
|
||||||
|
internal static CreateSessionCommand BuildCommand(
|
||||||
|
WizardDraft draft,
|
||||||
|
WizardPayload p,
|
||||||
|
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||||
|
int? maxPlayers,
|
||||||
|
bool isOneShot)
|
||||||
|
{
|
||||||
|
var user = new PlatformUser(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
draft.OwnerId,
|
||||||
|
DisplayName: string.Empty,
|
||||||
|
ExternalUsername: null);
|
||||||
|
var group = new PlatformGroup(
|
||||||
|
PlatformKind.Telegram,
|
||||||
|
draft.ChatId,
|
||||||
|
DisplayName: string.Empty,
|
||||||
|
ExternalChannelId: null,
|
||||||
|
ExternalThreadId: draft.MessageThreadId);
|
||||||
|
return new CreateSessionCommand(
|
||||||
|
User: user,
|
||||||
|
Group: group,
|
||||||
|
Title: p.Title ?? string.Empty,
|
||||||
|
Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
|
||||||
|
ScheduledTimes: scheduledTimes,
|
||||||
|
MaxPlayers: maxPlayers,
|
||||||
|
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
||||||
|
System: ParseSystem(p.System),
|
||||||
|
Description: p.Description,
|
||||||
|
Format: p.Format?.ToString(),
|
||||||
|
DurationMinutes: p.DurationMinutes,
|
||||||
|
IsOneShot: isOneShot,
|
||||||
|
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameSystem? ParseSystem(string? code)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(code)) return null;
|
||||||
|
return Enum.TryParse<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────────────────────────
|
||||||
|
private static bool IsComplete(WizardPayload p, out string missing)
|
||||||
|
{
|
||||||
|
var missingFields = new List<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
||||||
|
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
||||||
|
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
|
||||||
|
if (p.Format is null) missingFields.Add("формат");
|
||||||
|
if (p.Format == WizardSessionFormat.Online && string.IsNullOrWhiteSpace(p.JoinLink)) missingFields.Add("ссылка");
|
||||||
|
if (p.Format == WizardSessionFormat.Offline && string.IsNullOrWhiteSpace(p.LocationAddress)) missingFields.Add("адрес");
|
||||||
|
if (p.Visibility is null) missingFields.Add("видимость");
|
||||||
|
|
||||||
|
if (p.Type == WizardCreationType.Single)
|
||||||
|
{
|
||||||
|
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||||
|
// MaxPlayers = null is a valid "♾ Без лимита" choice
|
||||||
|
// (see GameCreationWizard.ApplyCapacityChoice "no_limit").
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты");
|
||||||
|
}
|
||||||
|
missing = string.Join(", ", missingFields);
|
||||||
|
return missingFields.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payload I/O ──────────────────────────────────────────────────
|
||||||
|
private static WizardPayload LoadPayload(WizardDraft draft)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
|
||||||
|
return JsonSerializer.Deserialize(draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SavePayload(WizardDraft draft, WizardPayload p)
|
||||||
|
{
|
||||||
|
draft.PayloadJson = JsonSerializer.Serialize(p, WizardPayloadJsonContext.Default.WizardPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboards ────────────────────────────────────────────────────
|
||||||
|
private static IReadOnlyList<WizardAction> RetryCancelActions() => new[]
|
||||||
|
{
|
||||||
|
new WizardAction("🔁 Повторить", WizardCallbackData.Create(), WizardActionStyle.Primary),
|
||||||
|
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
using GmRelay.Shared.Domain;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
|
||||||
|
|
||||||
internal sealed record NewSessionParseResult(
|
|
||||||
string? Title,
|
|
||||||
string? Link,
|
|
||||||
int? MaxPlayers,
|
|
||||||
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
|
||||||
IReadOnlyList<string> PastTimeInputs,
|
|
||||||
IReadOnlyList<string> InvalidTimeInputs,
|
|
||||||
IReadOnlyList<string> InvalidSeatLimitInputs)
|
|
||||||
{
|
|
||||||
public bool IsValid =>
|
|
||||||
!string.IsNullOrWhiteSpace(Title) &&
|
|
||||||
!string.IsNullOrWhiteSpace(Link) &&
|
|
||||||
ScheduledTimes.Count > 0 &&
|
|
||||||
InvalidSeatLimitInputs.Count == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class NewSessionCommandParser
|
|
||||||
{
|
|
||||||
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[] SeatLimitPrefixes =
|
|
||||||
[
|
|
||||||
"\u041c\u0435\u0441\u0442:",
|
|
||||||
"\u041b\u0438\u043c\u0438\u0442:",
|
|
||||||
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
|
|
||||||
];
|
|
||||||
|
|
||||||
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
|
|
||||||
{
|
|
||||||
string? title = null;
|
|
||||||
string? link = null;
|
|
||||||
int? maxPlayers = null;
|
|
||||||
var scheduledTimes = new List<DateTimeOffset>();
|
|
||||||
var pastTimeInputs = new List<string>();
|
|
||||||
var invalidTimeInputs = new List<string>();
|
|
||||||
var invalidSeatLimitInputs = new List<string>();
|
|
||||||
|
|
||||||
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
|
|
||||||
{
|
|
||||||
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
title = line[TitlePrefix.Length..].Trim();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
link = line[LinkPrefix.Length..].Trim();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
|
|
||||||
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (seatLimitPrefix is not null)
|
|
||||||
{
|
|
||||||
var seatLimitInput = line[seatLimitPrefix.Length..].Trim();
|
|
||||||
if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0)
|
|
||||||
{
|
|
||||||
maxPlayers = parsedMaxPlayers;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
invalidSeatLimitInputs.Add(seatLimitInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeInput = line[TimePrefix.Length..].Trim();
|
|
||||||
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
|
|
||||||
{
|
|
||||||
invalidTimeInputs.Add(timeInput);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scheduledAt <= nowUtc)
|
|
||||||
{
|
|
||||||
pastTimeInputs.Add(timeInput);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduledTimes.Add(scheduledAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NewSessionParseResult(
|
|
||||||
title,
|
|
||||||
link,
|
|
||||||
maxPlayers,
|
|
||||||
scheduledTimes,
|
|
||||||
pastTimeInputs,
|
|
||||||
invalidTimeInputs,
|
|
||||||
invalidSeatLimitInputs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
@@ -13,12 +14,12 @@ public sealed record PromoteWaitlistedPlayerCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
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);
|
internal sealed record WaitlistedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||||||
|
|
||||||
public sealed class PromoteWaitlistedPlayerHandler(
|
public sealed class PromoteWaitlistedPlayerHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
ILogger<PromoteWaitlistedPlayerHandler> logger)
|
ILogger<PromoteWaitlistedPlayerHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
|
public async Task HandleAsync(PromoteWaitlistedPlayerCommand command, CancellationToken ct)
|
||||||
@@ -33,27 +34,34 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT s.title AS Title,
|
SELECT s.title AS Title,
|
||||||
s.batch_id AS BatchId,
|
s.batch_id AS BatchId,
|
||||||
|
s.batch_message_id AS BatchMessageId,
|
||||||
s.max_players AS MaxPlayers,
|
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
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
""",
|
""",
|
||||||
new { command.SessionId },
|
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.GmId != command.TelegramUserId)
|
if (!session.CanManage)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,14 +90,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
if (waitlistedParticipants == 0)
|
if (waitlistedParticipants == 0)
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Лист ожидания пуст.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Лист ожидания пуст.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
|
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
await transaction.RollbackAsync(ct);
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", showAlert: true, cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Нет свободных мест. Увеличьте лимит перед повышением игрока.", ct, showAlert: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +138,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
SELECT id AS SessionId,
|
SELECT id AS SessionId,
|
||||||
scheduled_at AS ScheduledAt,
|
scheduled_at AS ScheduledAt,
|
||||||
status AS Status,
|
status AS Status,
|
||||||
max_players AS MaxPlayers
|
max_players AS MaxPlayers,
|
||||||
|
join_link AS JoinLink,
|
||||||
|
format AS Format,
|
||||||
|
location_address AS LocationAddress,
|
||||||
|
description AS Description,
|
||||||
|
system AS System,
|
||||||
|
duration_minutes AS DurationMinutes,
|
||||||
|
is_one_shot AS IsOneShot
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at
|
ORDER BY scheduled_at
|
||||||
@@ -142,7 +157,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId,
|
SELECT sp.session_id AS SessionId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.external_username AS TelegramUsername,
|
||||||
sp.registration_status AS RegistrationStatus
|
sp.registration_status AS RegistrationStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
@@ -156,17 +171,16 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
transactionCommitted = true;
|
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(
|
await AnswerAsync(command.CallbackQueryId, $"{promoted.DisplayName} переведен(а) в основной состав.", ct);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -179,7 +193,10 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
var errorText = transactionCommitted
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Dapper;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Npgsql;
|
||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telegram-side implementation of <see cref="IWizardMessenger"/>.
|
||||||
|
/// Translates the platform-neutral wizard contracts into the
|
||||||
|
/// <c>Telegram.Bot</c> SDK calls. All Telegram-specific behaviour
|
||||||
|
/// (message editing, callback ack, group lookup) lives behind the
|
||||||
|
/// interface so the wizard core stays in <c>GmRelay.Shared</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelegramWizardMessenger(
|
||||||
|
ITelegramBotClient bot,
|
||||||
|
NpgsqlDataSource dataSource) : IWizardMessenger
|
||||||
|
{
|
||||||
|
public async Task<string> EditDraftMessageAsync(
|
||||||
|
WizardDraft draft,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!TryParseChatId(draft.ChatId, out var chatId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
||||||
|
}
|
||||||
|
if (!TryParseMessageId(draft.DraftMessageId, out var messageId))
|
||||||
|
{
|
||||||
|
// No draft message recorded yet — fall back to sending a new one.
|
||||||
|
return await SendDraftMessageAsync(draft, text, keyboard, ct);
|
||||||
|
}
|
||||||
|
var msg = await bot.EditMessageText(
|
||||||
|
chatId: chatId,
|
||||||
|
messageId: messageId,
|
||||||
|
text: text,
|
||||||
|
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||||
|
cancellationToken: ct);
|
||||||
|
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> SendDraftMessageAsync(
|
||||||
|
WizardDraft draft,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<WizardAction> keyboard,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!TryParseChatId(draft.ChatId, out var chatId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Wizard draft {draft.Id} has un-parseable chat id '{draft.ChatId}'.");
|
||||||
|
}
|
||||||
|
int? threadId = TryParseThreadId(draft.MessageThreadId, out var parsedThread)
|
||||||
|
? parsedThread
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var msg = await bot.SendMessage(
|
||||||
|
chatId: chatId,
|
||||||
|
text: text,
|
||||||
|
messageThreadId: threadId,
|
||||||
|
replyMarkup: WizardStep.ToInlineKeyboard(keyboard),
|
||||||
|
cancellationToken: ct);
|
||||||
|
return msg.MessageId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AnswerInteractionAsync(string interactionId, string? text, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return bot.AnswerCallbackQuery(interactionId, text: text, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WizardClubOption>> GetOwnerClubsAsync(string ownerId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Adjusted from the plan: this codebase models "clubs" as game_groups
|
||||||
|
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
|
||||||
|
// and game_groups has no `club_id` FK). The picker therefore returns the
|
||||||
|
// game_groups the owner manages as a GM (via group_managers), matching
|
||||||
|
// the WizardClubOption contract (UUID id, name) used downstream.
|
||||||
|
//
|
||||||
|
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
|
||||||
|
// (sql, object?) extension overload — not the (CommandDefinition) overload.
|
||||||
|
// The wizard reaches this method on the PickClub visibility step
|
||||||
|
// (issue #112 follow-up); using CommandDefinition here would fall back
|
||||||
|
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
|
||||||
|
// and throws PlatformNotSupportedException on AOT. Same root cause as
|
||||||
|
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
|
||||||
|
const string sql = """
|
||||||
|
SELECT g.id AS ClubId,
|
||||||
|
g.name AS Name
|
||||||
|
FROM game_groups g
|
||||||
|
JOIN group_managers gm ON gm.group_id = g.id
|
||||||
|
JOIN players p ON p.id = gm.player_id
|
||||||
|
WHERE p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalId
|
||||||
|
GROUP BY g.id, g.name
|
||||||
|
ORDER BY g.name
|
||||||
|
""";
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
var rows = await connection.QueryAsync<WizardClubOption>(
|
||||||
|
sql,
|
||||||
|
new { Platform = "Telegram", ExternalId = ownerId });
|
||||||
|
return rows.AsList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseChatId(string raw, out long chatId)
|
||||||
|
{
|
||||||
|
if (long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out chatId))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
chatId = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseMessageId(string? raw, out int messageId)
|
||||||
|
{
|
||||||
|
if (raw is not null &&
|
||||||
|
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out messageId))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
messageId = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseThreadId(string? raw, out int threadId)
|
||||||
|
{
|
||||||
|
if (raw is not null &&
|
||||||
|
int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out threadId))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
threadId = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
public sealed class WizardDraftCleanupService : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
private readonly IWizardDraftRepository _drafts;
|
||||||
|
private readonly ILogger<WizardDraftCleanupService> _log;
|
||||||
|
|
||||||
|
public WizardDraftCleanupService(
|
||||||
|
IWizardDraftRepository drafts,
|
||||||
|
ILogger<WizardDraftCleanupService> log)
|
||||||
|
{
|
||||||
|
_drafts = drafts;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TickInterval);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
await RunOnceAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// graceful shutdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task RunOnceAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deleted = await _drafts.DeleteExpiredAsync(ct);
|
||||||
|
if (deleted > 0)
|
||||||
|
{
|
||||||
|
_log.LogInformation("Wizard cleanup deleted {Count} expired drafts", deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "Wizard cleanup tick failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a Telegram <see cref="Update"/> into the
|
||||||
|
/// platform-neutral <see cref="WizardInteraction"/> consumed by
|
||||||
|
/// <see cref="GameCreationWizard"/>. The mapping is the only place in
|
||||||
|
/// the bot that knows about both <c>Telegram.Bot.Types</c> and the
|
||||||
|
/// shared wizard contract, so a future Discord adapter can do the same
|
||||||
|
/// for its native event without changing the wizard core.
|
||||||
|
/// </summary>
|
||||||
|
public static class WizardInteractionMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>true</c> if <paramref name="update"/> carries a
|
||||||
|
/// wizard-relevant interaction (text message, photo, or
|
||||||
|
/// callback). Side-effect-free: the wizard state is not touched.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryMap(Update update, out WizardInteraction interaction)
|
||||||
|
{
|
||||||
|
interaction = default!;
|
||||||
|
if (update.CallbackQuery is { } cb && cb.From is not null)
|
||||||
|
{
|
||||||
|
interaction = new WizardInteraction(
|
||||||
|
OwnerId: cb.From.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Text: null,
|
||||||
|
CallbackPayload: cb.Data,
|
||||||
|
PhotoFileId: null,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: cb.Id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.Message is { From: not null } msg)
|
||||||
|
{
|
||||||
|
// The original Telegram wizard dispatched on
|
||||||
|
// `msg.Text is null` to identify a non-text update (photo,
|
||||||
|
// document, sticker, …) and only ran the text pipeline
|
||||||
|
// otherwise. We preserve that semantic: a message that
|
||||||
|
// carries a photo is a photo interaction even if it has a
|
||||||
|
// caption. Text is null for photos; the wizard checks
|
||||||
|
// PhotoFileId separately when Text is null.
|
||||||
|
//
|
||||||
|
// Note: `Message.MessageId` is exposed as a read-only
|
||||||
|
// property in Telegram.Bot, so the mapper cannot embed the
|
||||||
|
// numeric id in the interaction. Text interactions never
|
||||||
|
// need an ack, so the InteractionId is unused for them —
|
||||||
|
// we just emit a stable sentinel.
|
||||||
|
var hasPhoto = msg.Photo is { Length: > 0 };
|
||||||
|
var text = hasPhoto ? null : msg.Text;
|
||||||
|
var photoFileId = hasPhoto ? msg.Photo![^1].FileId : null;
|
||||||
|
interaction = new WizardInteraction(
|
||||||
|
OwnerId: msg.From!.Id.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Text: text,
|
||||||
|
CallbackPayload: null,
|
||||||
|
PhotoFileId: photoFileId,
|
||||||
|
PhotoUrl: null,
|
||||||
|
InteractionId: "msg");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telegram-side renderer for wizard keyboards. Acts as the adapter
|
||||||
|
/// between the platform-neutral <see cref="WizardAction"/> list
|
||||||
|
/// produced by <see cref="WizardStepViewBuilder"/> and Telegram's
|
||||||
|
/// <see cref="InlineKeyboardMarkup"/>. Each <see cref="WizardAction"/>
|
||||||
|
/// becomes its own row (matching the pre-refactor Telegram layout).
|
||||||
|
/// <see cref="WizardActionStyle"/> is currently ignored by Telegram
|
||||||
|
/// because the platform has no native primary/danger/success button
|
||||||
|
/// colours.
|
||||||
|
/// </summary>
|
||||||
|
public static class WizardStep
|
||||||
|
{
|
||||||
|
public const int MaxTitleLength = WizardStepLimits.MaxTitleLength;
|
||||||
|
public const int MaxDescriptionLength = WizardStepLimits.MaxDescriptionLength;
|
||||||
|
public const int MaxSystemLength = WizardStepLimits.MaxSystemLength;
|
||||||
|
public const int MaxCapacity = WizardStepLimits.MaxCapacity;
|
||||||
|
public const int MinCapacity = WizardStepLimits.MinCapacity;
|
||||||
|
public const int MinDurationHours = WizardStepLimits.MinDurationHours;
|
||||||
|
public const int MaxDurationHours = WizardStepLimits.MaxDurationHours;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render the platform-neutral view into a (text, Telegram keyboard)
|
||||||
|
/// pair. Used by the wizard's surrounding code (router, create
|
||||||
|
/// handler) when it needs to send a fresh draft message or render
|
||||||
|
/// the resume/reset menu.
|
||||||
|
/// </summary>
|
||||||
|
public static (string Text, InlineKeyboardMarkup Keyboard) Render(
|
||||||
|
WizardDraft draft,
|
||||||
|
WizardPayload payload,
|
||||||
|
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||||
|
{
|
||||||
|
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
|
||||||
|
return (text, ToInlineKeyboard(actions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert a flat list of <see cref="WizardAction"/>s into a
|
||||||
|
/// Telegram keyboard. Each action is placed in its own row to
|
||||||
|
/// preserve the pre-refactor visual layout.
|
||||||
|
/// </summary>
|
||||||
|
public static InlineKeyboardMarkup ToInlineKeyboard(IReadOnlyList<WizardAction> actions)
|
||||||
|
{
|
||||||
|
if (actions.Count == 0)
|
||||||
|
{
|
||||||
|
return new InlineKeyboardMarkup(Array.Empty<InlineKeyboardButton[]>());
|
||||||
|
}
|
||||||
|
var rows = new InlineKeyboardButton[actions.Count][];
|
||||||
|
for (var i = 0; i < actions.Count; i++)
|
||||||
|
{
|
||||||
|
rows[i] = new[]
|
||||||
|
{
|
||||||
|
InlineKeyboardButton.WithCallbackData(actions[i].Label, actions[i].Payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return new InlineKeyboardMarkup(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,77 +1,25 @@
|
|||||||
using System.Text;
|
using GmRelay.Shared.Platform;
|
||||||
using Dapper;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot;
|
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
|
||||||
|
|
||||||
public sealed class ExportCalendarHandler(
|
public sealed class ExportCalendarHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
|
||||||
ITelegramBotClient botClient)
|
|
||||||
{
|
{
|
||||||
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>(
|
return sharedHandler.HandleAsync(command, cancellationToken);
|
||||||
@"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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using Dapper;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using Npgsql;
|
using GmRelay.Shared.Platform;
|
||||||
using Telegram.Bot;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
||||||
|
|
||||||
@@ -12,119 +10,88 @@ public sealed record DeleteSessionCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
internal sealed record DeleteSessionInfoDto(string Title, Guid BatchId, long GmId, int? ThreadId);
|
|
||||||
|
|
||||||
public sealed class DeleteSessionHandler(
|
public sealed class DeleteSessionHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
|
||||||
ITelegramBotClient bot,
|
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
ILogger<DeleteSessionHandler> logger)
|
ILogger<DeleteSessionHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var platformUser = new PlatformUser(
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
PlatformKind.Telegram,
|
||||||
|
command.TelegramUserId.ToString(),
|
||||||
|
string.Empty,
|
||||||
|
null);
|
||||||
|
|
||||||
// 1. Fetch session and verify GM
|
var platformGroup = new PlatformGroup(
|
||||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
PlatformKind.Telegram,
|
||||||
@"SELECT s.title as Title, s.batch_id as BatchId, s.thread_id as ThreadId, g.gm_telegram_id as GmId
|
command.ChatId.ToString(),
|
||||||
FROM sessions s
|
string.Empty);
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
|
||||||
WHERE s.id = @SessionId",
|
|
||||||
new { command.SessionId }, transaction);
|
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.GmId != command.TelegramUserId)
|
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
|
||||||
{
|
if (result.ThreadId.HasValue &&
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только Мастер Игры (GM) может удалять сессию.", showAlert: true, cancellationToken: ct);
|
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
|
await messenger.DeleteThreadAsync(
|
||||||
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
|
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)
|
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)
|
// 5. Update the /listsessions message
|
||||||
// A simple way is to re-render the list:
|
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
|
||||||
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
|
var listResult = await listSessionsHandler.HandleAsync(listCommand, 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
|
|
||||||
});
|
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
if (listResult.Sessions.Count == 0)
|
||||||
|
|
||||||
if (sessionsList.Count == 0)
|
|
||||||
{
|
{
|
||||||
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch {}
|
try
|
||||||
|
{
|
||||||
|
await messenger.UpdateGroupMessageAsync(
|
||||||
|
scheduleMessage,
|
||||||
|
"📭 В этой группе нет предстоящих игр.",
|
||||||
|
[],
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
|
||||||
foreach (var s in sessionsList)
|
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
|
||||||
{
|
|
||||||
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;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.EditMessageText(
|
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
|
||||||
command.ChatId,
|
|
||||||
command.MessageId,
|
|
||||||
text,
|
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
||||||
replyMarkup: keyboard,
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,72 +1,37 @@
|
|||||||
using Dapper;
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using Npgsql;
|
|
||||||
using Telegram.Bot;
|
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ListSessions;
|
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(
|
public sealed class ListSessionsHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
|
||||||
ITelegramBotClient botClient)
|
IPlatformMessenger messenger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
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>(
|
var result = await sharedHandler.HandleAsync(command, cancellationToken);
|
||||||
@"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 sessionsList = sessions.ToList();
|
if (result.Sessions.Count == 0)
|
||||||
|
|
||||||
if (sessionsList.Count == 0)
|
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
|
||||||
chatId: message.Chat.Id,
|
|
||||||
text: "📭 В этой группе нет предстоящих игр.",
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var text = "📅 <b>Ближайшие игры:</b>\n\n";
|
var text = SessionListMessageRenderer.RenderText(result.Sessions);
|
||||||
foreach (var s in sessionsList)
|
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
|
||||||
{
|
|
||||||
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 = message.From?.Id == sessionsList.First().GmId;
|
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
-202
@@ -1,299 +1,269 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Bot.Features.Notifications;
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
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, string NotificationMode);
|
|
||||||
|
|
||||||
internal sealed record VoteParticipantDto(
|
|
||||||
Guid PlayerId,
|
|
||||||
string DisplayName,
|
|
||||||
string? TelegramUsername,
|
|
||||||
long TelegramId = 0);
|
|
||||||
|
|
||||||
// ── Handler ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles text input from the GM who has an AwaitingTime proposal.
|
/// Telegram adapter for reschedule time input.
|
||||||
/// Parses the new time, creates a voting message, and tags all participants.
|
/// Delegates core logic to the shared handler, then performs Telegram-specific
|
||||||
/// If no participants are registered, reschedules immediately.
|
/// message sending, DM notifications, vote_message_id storage, and cleanup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HandleRescheduleTimeInputHandler(
|
public sealed class HandleRescheduleTimeInputHandler(
|
||||||
|
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
IPlatformMessenger messenger,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<HandleRescheduleTimeInputHandler> logger)
|
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)
|
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var gmTelegramId = message.From.Id;
|
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
|
||||||
var chatId = message.Chat.Id;
|
new PlatformUser(
|
||||||
var text = message.Text.Trim();
|
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);
|
var result = await sharedHandler.HandleAsync(command, ct);
|
||||||
|
if (!result.Handled)
|
||||||
// 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,
|
|
||||||
s.notification_mode AS NotificationMode
|
|
||||||
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)
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// 2. Parse the new time
|
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
|
||||||
if (!MoscowTime.TryParseMoscow(text, out var newTime))
|
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await messenger.SendGroupMessageAsync(
|
||||||
chatId: chatId,
|
command.Group,
|
||||||
text: "⚠️ Не удалось распознать время. Используйте формат: <code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\nНапример: <code>25.04.2026 19:30</code>",
|
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
ct);
|
||||||
cancellationToken: ct);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newTime <= DateTimeOffset.UtcNow)
|
if (result.IsRescheduledImmediately)
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
|
||||||
chatId: chatId,
|
{
|
||||||
text: "⚠️ Новое время должно быть в будущем. Попробуйте снова.",
|
await TryUpdateBatchMessage(
|
||||||
cancellationToken: ct);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Load participants (non-GM) signed up for this session
|
// Voting mode
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
var voteText = BuildVotingMessage(
|
||||||
"""
|
result.Title!,
|
||||||
SELECT p.id AS PlayerId,
|
result.CurrentScheduledAt,
|
||||||
p.display_name AS DisplayName,
|
result.VotingDeadlineAt!.Value,
|
||||||
p.telegram_username AS TelegramUsername,
|
result.Options,
|
||||||
p.telegram_id AS TelegramId
|
result.Participants,
|
||||||
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();
|
|
||||||
|
|
||||||
// 4. If no participants — reschedule immediately
|
var keyboard = BuildVotingKeyboard(result.Options);
|
||||||
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 voteMsg = await bot.SendMessage(
|
var voteMsg = await bot.SendMessage(
|
||||||
chatId: chatId,
|
chatId: message.Chat.Id,
|
||||||
|
messageThreadId: message.MessageThreadId,
|
||||||
text: voteText,
|
text: voteText,
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
replyMarkup: keyboard,
|
replyMarkup: keyboard,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
|
||||||
if (mode.ShouldSendDirectMessages())
|
if (mode.ShouldSendDirectMessages())
|
||||||
{
|
{
|
||||||
|
var optionsText = string.Join(
|
||||||
|
"\n",
|
||||||
|
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
||||||
var directText = $"""
|
var directText = $"""
|
||||||
🔄 <b>Голосование за перенос сессии</b>
|
🔄 <b>Голосование за перенос сессии</b>
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
|
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
||||||
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
||||||
📅 Новое время: <b>{newTime.FormatMoscow()}</b> (МСК)
|
🗳 Варианты:
|
||||||
|
{optionsText}
|
||||||
|
|
||||||
|
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
|
||||||
|
|
||||||
Проголосуйте кнопкой в групповом сообщении.
|
Проголосуйте кнопкой в групповом сообщении.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await directSender.SendAsync(
|
await directSender.SendAsync(
|
||||||
participants.Select(p => new DirectNotificationRecipient(
|
result.Participants.Select(p => new DirectNotificationRecipient(
|
||||||
p.TelegramId,
|
p.TelegramId,
|
||||||
p.DisplayName)),
|
p.DisplayName)),
|
||||||
directText,
|
directText,
|
||||||
"reschedule-vote",
|
"reschedule-vote",
|
||||||
proposal.SessionId,
|
result.ProposalId.Value,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store vote message ID
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
"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);
|
logger.LogInformation(
|
||||||
|
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
||||||
// Delete GM's time input message
|
result.ProposalId.Value,
|
||||||
await TryDeleteMessage(chatId, message.MessageId, ct);
|
result.ProposalId.Value,
|
||||||
|
result.Options.Count,
|
||||||
|
result.VotingDeadlineAt.Value);
|
||||||
|
|
||||||
|
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RescheduleImmediately(
|
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
|
||||||
NpgsqlConnection connection, AwaitingProposalDto proposal,
|
|
||||||
DateTimeOffset newTime, long chatId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
"""
|
||||||
UPDATE sessions
|
SELECT s.notification_mode
|
||||||
SET scheduled_at = @NewTime,
|
FROM sessions s
|
||||||
status = @Status,
|
JOIN reschedule_proposals rp ON rp.session_id = s.id
|
||||||
one_hour_reminder_processed_at = NULL,
|
WHERE rp.id = @Id
|
||||||
updated_at = now()
|
|
||||||
WHERE id = @SessionId
|
|
||||||
""",
|
""",
|
||||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
new { Id = proposalId });
|
||||||
transaction);
|
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
await connection.ExecuteAsync(
|
private async Task TryUpdateBatchMessage(
|
||||||
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
PlatformGroup group,
|
||||||
new { NewTime = newTime, Id = proposal.Id },
|
SessionBatchViewModel view,
|
||||||
transaction);
|
PlatformMessageRef scheduleMessage,
|
||||||
|
CancellationToken ct)
|
||||||
await transaction.CommitAsync(ct);
|
{
|
||||||
|
try
|
||||||
await bot.SendMessage(
|
{
|
||||||
chatId: chatId,
|
await messenger.UpdateScheduleAsync(
|
||||||
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>",
|
new PlatformScheduleMessage(group, view, scheduleMessage),
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
ct);
|
||||||
cancellationToken: ct);
|
}
|
||||||
|
catch (Exception ex)
|
||||||
// Re-render batch message with updated time
|
{
|
||||||
await TryUpdateBatchMessage(proposal, ct);
|
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
|
||||||
|
}
|
||||||
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string BuildVotingMessage(
|
internal static string BuildVotingMessage(
|
||||||
string title, DateTime currentTime, DateTimeOffset newTime,
|
string title,
|
||||||
|
DateTime currentTime,
|
||||||
|
DateTimeOffset deadline,
|
||||||
|
IReadOnlyList<RescheduleOptionDto> options,
|
||||||
IReadOnlyList<VoteParticipantDto> participants,
|
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>
|
var lines = new List<string>
|
||||||
{
|
{
|
||||||
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
|
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
|
||||||
"",
|
"",
|
||||||
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</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 optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
|
||||||
var icon = approvedPlayerIds.Contains(p.PlayerId) ? "✅" : "⏳";
|
lines.Add(
|
||||||
lines.Add($" {icon} {name}");
|
$"{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("");
|
||||||
lines.Add($"Голоса: {approvedPlayerIds.Count}/{participants.Count} ✅");
|
lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
|
||||||
|
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
|
||||||
|
|
||||||
return string.Join("\n", lines);
|
return string.Join("\n", lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
|
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
|
||||||
{
|
{
|
||||||
try
|
return new InlineKeyboardMarkup(
|
||||||
{
|
options
|
||||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
.OrderBy(option => option.DisplayOrder)
|
||||||
|
.Select(option => new[]
|
||||||
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",
|
InlineKeyboardButton.WithCallbackData(
|
||||||
new { proposal.BatchId })).ToList();
|
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
|
||||||
|
$"reschedule_vote:{option.OptionId}")
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
+48
-329
@@ -1,361 +1,80 @@
|
|||||||
using Dapper;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Bot.Features.Notifications;
|
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||||
using Npgsql;
|
using GmRelay.Shared.Platform;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
public sealed record HandleRescheduleVoteCommand(
|
public sealed record HandleRescheduleVoteCommand(
|
||||||
Guid ProposalId,
|
Guid OptionId,
|
||||||
string Vote,
|
|
||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
string CallbackQueryId,
|
string CallbackQueryId,
|
||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
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,
|
|
||||||
string NotificationMode);
|
|
||||||
|
|
||||||
public sealed class HandleRescheduleVoteHandler(
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
DirectSessionNotificationSender directSender,
|
IPlatformMessenger messenger,
|
||||||
ILogger<HandleRescheduleVoteHandler> logger)
|
ILogger<HandleRescheduleVoteHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var platformUser = new PlatformUser(
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
PlatformKind.Telegram,
|
||||||
|
command.TelegramUserId.ToString(),
|
||||||
|
string.Empty,
|
||||||
|
null);
|
||||||
|
|
||||||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
var platformGroup = new PlatformGroup(
|
||||||
"""
|
PlatformKind.Telegram,
|
||||||
SELECT rp.id AS Id,
|
command.ChatId.ToString(),
|
||||||
rp.session_id AS SessionId,
|
string.Empty);
|
||||||
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,
|
|
||||||
s.notification_mode AS NotificationMode
|
|
||||||
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);
|
|
||||||
|
|
||||||
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(
|
await messenger.AnswerInteractionAsync(
|
||||||
command.CallbackQueryId,
|
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
|
||||||
"Голосование уже завершено или не найдено.",
|
ct);
|
||||||
cancellationToken: ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
"""
|
result.Title!,
|
||||||
SELECT p.id
|
result.CurrentScheduledAt,
|
||||||
FROM session_participants sp
|
result.VotingDeadlineAt,
|
||||||
JOIN players p ON p.id = sp.player_id
|
result.Options,
|
||||||
WHERE sp.session_id = @SessionId
|
result.Participants,
|
||||||
AND p.telegram_id = @TelegramUserId
|
result.Votes);
|
||||||
AND sp.is_gm = false
|
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
|
||||||
AND sp.registration_status = @Active
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
|
|
||||||
transaction);
|
|
||||||
|
|
||||||
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,
|
|
||||||
p.telegram_id 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 { 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)
|
|
||||||
{
|
|
||||||
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
|
||||||
if (mode.ShouldSendDirectMessages())
|
|
||||||
{
|
|
||||||
await directSender.SendAsync(
|
|
||||||
directRecipients,
|
|
||||||
$"❌ <b>Перенос сессии отклонён</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Время остаётся прежним: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)",
|
|
||||||
"reschedule-rejected",
|
|
||||||
proposal.SessionId,
|
|
||||||
ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decision.ShouldRescheduleSession)
|
|
||||||
{
|
|
||||||
var directRecipients = await LoadDirectRecipients(connection, proposal.SessionId, transaction);
|
|
||||||
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,
|
|
||||||
one_hour_reminder_processed_at = 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);
|
|
||||||
|
|
||||||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
|
|
||||||
if (mode.ShouldSendDirectMessages())
|
|
||||||
{
|
|
||||||
await directSender.SendAsync(
|
|
||||||
directRecipients,
|
|
||||||
$"✅ <b>Сессия перенесена</b>\n\n📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>\n📅 Новое время: <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК)",
|
|
||||||
"reschedule-approved",
|
|
||||||
proposal.SessionId,
|
|
||||||
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 static async Task<List<DirectNotificationRecipient>> LoadDirectRecipients(
|
|
||||||
Npgsql.NpgsqlConnection connection,
|
|
||||||
Guid sessionId,
|
|
||||||
Npgsql.NpgsqlTransaction transaction)
|
|
||||||
{
|
|
||||||
return (await connection.QueryAsync<DirectNotificationRecipient>(
|
|
||||||
"""
|
|
||||||
SELECT p.telegram_id AS TelegramId,
|
|
||||||
p.display_name AS DisplayName
|
|
||||||
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 },
|
|
||||||
transaction)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await bot.EditMessageText(
|
||||||
|
chatId: command.ChatId,
|
||||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
messageId: command.MessageId,
|
||||||
"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",
|
text: voteText,
|
||||||
new { proposal.BatchId })).ToList();
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
cancellationToken: ct);
|
||||||
"""
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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 Dapper;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
@@ -12,48 +13,55 @@ public sealed record InitiateRescheduleCommand(
|
|||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
string CallbackQueryId,
|
string CallbackQueryId,
|
||||||
long ChatId,
|
long ChatId,
|
||||||
|
int? MessageThreadId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
internal sealed record RescheduleSessionInfoDto(string Title, long GmId);
|
internal sealed record RescheduleSessionInfoDto(string Title, bool CanManage);
|
||||||
|
|
||||||
// ── Handler ──────────────────────────────────────────────────────────
|
// ── Handler ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the "⏰ Перенести" button press from the batch message.
|
/// Handles the "⏰ Перенести" button press from the batch message.
|
||||||
/// Creates a reschedule proposal in AwaitingTime status and prompts
|
/// 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>
|
/// </summary>
|
||||||
public sealed class InitiateRescheduleHandler(
|
public sealed class InitiateRescheduleHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
IPlatformMessenger messenger,
|
||||||
ILogger<InitiateRescheduleHandler> logger)
|
ILogger<InitiateRescheduleHandler> logger)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
|
public async Task HandleAsync(InitiateRescheduleCommand command, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
// 1. Verify GM ownership
|
// 1. Verify group management access.
|
||||||
var session = await connection.QuerySingleOrDefaultAsync<RescheduleSessionInfoDto>(
|
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
|
FROM sessions s
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
|
||||||
WHERE s.id = @SessionId AND s.status != @Cancelled
|
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)
|
if (session is null)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
|
await AnswerAsync(command.CallbackQueryId, "Сессия не найдена.", ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.GmId != command.TelegramUserId)
|
if (!session.CanManage)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await AnswerAsync(command.CallbackQueryId, "Только owner или co-GM может переносить сессию.", ct, showAlert: true);
|
||||||
"Только Мастер Игры (GM) может переносить сессию.", showAlert: true, cancellationToken: ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,29 +77,43 @@ public sealed class InitiateRescheduleHandler(
|
|||||||
|
|
||||||
if (hasActive)
|
if (hasActive)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await AnswerAsync(command.CallbackQueryId, "Уже есть активный запрос на перенос этой сессии.", ct, showAlert: true);
|
||||||
"Уже есть активный запрос на перенос этой сессии.", showAlert: true, cancellationToken: ct);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Create proposal in AwaitingTime status
|
// 3. Create proposal in AwaitingTime status
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
INSERT INTO reschedule_proposals (session_id, proposed_by, status)
|
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
|
||||||
VALUES (@SessionId, @GmId, 'AwaitingTime')
|
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);
|
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
|
||||||
|
|
||||||
// 4. Prompt GM in chat
|
// 4. Prompt GM in chat
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await AnswerAsync(command.CallbackQueryId, "Введите 2-3 варианта времени и дедлайн голосования.", ct);
|
||||||
"Введите новое время в чат (формат: ДД.ММ.ГГГГ ЧЧ:ММ)", cancellationToken: ct);
|
|
||||||
|
|
||||||
await bot.SendMessage(
|
var prompt = string.Join(
|
||||||
chatId: command.ChatId,
|
"\n",
|
||||||
text: $"⏰ Укажите новое время для сессии «{session.Title}» в формате:\n<code>ДД.ММ.ГГГГ ЧЧ:ММ</code>\n\nНапример: <code>25.04.2026 19:30</code>",
|
new[]
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
{
|
||||||
cancellationToken: ct);
|
$"⏰ Укажите 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, format AS Format, location_address AS LocationAddress, description AS Description, system AS System, duration_minutes AS DurationMinutes, is_one_shot AS IsOneShot 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,157 +0,0 @@
|
|||||||
using Dapper;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
|
||||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
|
||||||
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
|
||||||
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,
|
|
||||||
SendOneHourReminderHandler oneHourReminderHandler,
|
|
||||||
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 OneHourReminderLeadTime = TimeSpan.FromHours(1);
|
|
||||||
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 ProcessOneHourReminderTriggers(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-1h trigger: process direct reminders according to the session notification mode.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ProcessOneHourReminderTriggers(CancellationToken ct)
|
|
||||||
{
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
||||||
|
|
||||||
var sessionIds = await connection.QueryAsync<Guid>(
|
|
||||||
"""
|
|
||||||
SELECT id
|
|
||||||
FROM sessions
|
|
||||||
WHERE status IN (@Confirmed, @ConfirmationSent)
|
|
||||||
AND scheduled_at - @LeadTime <= now()
|
|
||||||
AND one_hour_reminder_processed_at IS NULL
|
|
||||||
""",
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Confirmed = SessionStatus.Confirmed,
|
|
||||||
ConfirmationSent = SessionStatus.ConfirmationSent,
|
|
||||||
LeadTime = OneHourReminderLeadTime
|
|
||||||
});
|
|
||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
|
||||||
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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,539 @@
|
|||||||
|
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 => BuildOneHourReminderDirectText(notification),
|
||||||
|
PlatformDirectSessionNotificationKind.JoinLink => BuildJoinLinkDirectText(notification),
|
||||||
|
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 BuildOneHourReminderDirectText(PlatformDirectSessionNotification notification)
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
"⏰ <b>Игра начнётся примерно через 1 час</b>",
|
||||||
|
string.Empty,
|
||||||
|
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>",
|
||||||
|
$"📅 {notification.ScheduledAt.FormatMoscow()} (МСК)"
|
||||||
|
};
|
||||||
|
AppendJoinLinkLine(lines, notification.JoinLink);
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildJoinLinkDirectText(PlatformDirectSessionNotification notification)
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
"🎮 <b>Игра начинается через 5 минут</b>",
|
||||||
|
string.Empty,
|
||||||
|
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>"
|
||||||
|
};
|
||||||
|
AppendJoinLinkLine(lines, notification.JoinLink);
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendJoinLinkLine(List<string> lines, string? joinLink)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(joinLink))
|
||||||
|
{
|
||||||
|
lines.Add($"🔗 {System.Net.WebUtility.HtmlEncode(joinLink)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,107 @@
|
|||||||
|
// 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";
|
||||||
|
|
||||||
|
var tags = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.System))
|
||||||
|
tags.Add($"<b>Система:</b> {System.Net.WebUtility.HtmlEncode(session.System)}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Format))
|
||||||
|
tags.Add($"<b>Формат:</b> {System.Net.WebUtility.HtmlEncode(session.Format)}");
|
||||||
|
tags.Add($"<b>Тип:</b> {(session.IsOneShot ? "One-shot" : "Кампания")}");
|
||||||
|
|
||||||
|
if (tags.Count > 0)
|
||||||
|
{
|
||||||
|
messageText += "🏷 " + string.Join(" · ", tags) + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.DurationMinutes.HasValue)
|
||||||
|
{
|
||||||
|
messageText += $"⏱ <b>Длительность:</b> {FormatDuration(session.DurationMinutes.Value)}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Description))
|
||||||
|
{
|
||||||
|
messageText += $"📝 <b>Описание:</b>\n{System.Net.WebUtility.HtmlEncode(session.Description)}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
var format = session.Format ?? string.Empty;
|
||||||
|
var isOnline = string.Equals(format, "Online", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isOffline = string.Equals(format, "Offline", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isHybrid = string.Equals(format, "Hybrid", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if ((isOnline || isHybrid) && !string.IsNullOrWhiteSpace(session.JoinLink))
|
||||||
|
{
|
||||||
|
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
|
||||||
|
messageText += $"🔗 <b>Ссылка:</b> <a href=\"{encodedLink}\">{encodedLink}</a>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress))
|
||||||
|
{
|
||||||
|
messageText += $"📍 <b>Адрес:</b> {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
messageText += session.MaxPlayers.HasValue
|
||||||
|
? $"👥 <b>Места:</b> {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||||
|
: $"👥 <b>Игроки ({session.ActivePlayerCount}):</b>\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 += $"⏳ <b>Лист ожидания ({session.WaitlistedPlayers.Count}):</b>\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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDuration(int minutes)
|
||||||
|
{
|
||||||
|
if (minutes <= 0) return "0 мин";
|
||||||
|
var hours = minutes / 60;
|
||||||
|
var mins = minutes % 60;
|
||||||
|
if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин";
|
||||||
|
if (hours > 0) return $"{hours} ч";
|
||||||
|
return $"{mins} мин";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,23 @@
|
|||||||
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
// ... UpdateRouter will have CancelSessionHandler and cancel_session route instead of close_recruitment
|
||||||
|
using System.Globalization;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
using GmRelay.Bot.Features.Sessions.ListSessions;
|
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.ExportCalendar;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.Enums;
|
using Telegram.Bot.Types.Enums;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
using SharedWizard = GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
@@ -18,7 +27,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UpdateRouter(
|
public sealed class UpdateRouter(
|
||||||
HandleRsvpHandler rsvpHandler,
|
HandleRsvpHandler rsvpHandler,
|
||||||
CreateSessionHandler createSessionHandler,
|
BotCreateSessionHandler createSessionHandler,
|
||||||
JoinSessionHandler joinSessionHandler,
|
JoinSessionHandler joinSessionHandler,
|
||||||
LeaveSessionHandler leaveSessionHandler,
|
LeaveSessionHandler leaveSessionHandler,
|
||||||
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
|
||||||
@@ -27,30 +36,172 @@ public sealed class UpdateRouter(
|
|||||||
ListSessionsHandler listSessionsHandler,
|
ListSessionsHandler listSessionsHandler,
|
||||||
ExportCalendarHandler exportCalendarHandler,
|
ExportCalendarHandler exportCalendarHandler,
|
||||||
InitiateRescheduleHandler initiateRescheduleHandler,
|
InitiateRescheduleHandler initiateRescheduleHandler,
|
||||||
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||||
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
BotRescheduleVoteHandler rescheduleVoteHandler,
|
||||||
|
SharedWizard wizard,
|
||||||
|
IWizardDraftRepository drafts,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
|
IConfiguration configuration,
|
||||||
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||||||
{
|
{
|
||||||
public async Task RouteAsync(Update update, CancellationToken ct)
|
public async Task RouteAsync(Update update, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
|
||||||
|
// (chat, thread, owner), every update routes to the wizard. The wizard is
|
||||||
|
// responsible for both text input and callback handling.
|
||||||
|
if (TryGetWizardContext(update, out _, out _, out var ownerId))
|
||||||
|
{
|
||||||
|
var draft = await drafts.GetActiveAsync("Telegram", ownerId, ct);
|
||||||
|
if (draft is not null)
|
||||||
|
{
|
||||||
|
// Resume / Reset / Cancel menu callbacks live in the router because
|
||||||
|
// they cross draft boundaries (reset deletes + recreates a fresh
|
||||||
|
// draft, which the wizard instance doesn't know how to do).
|
||||||
|
if (await TryHandleDraftControlCallbackAsync(update, draft, ct))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WizardInteractionMapper.TryMap(update, out var interaction))
|
||||||
|
{
|
||||||
|
await wizard.HandleInteractionAsync(interaction, draft, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
|
||||||
|
// acknowledges the callback; the actual session creation lives in
|
||||||
|
// CreateSessionHandler.
|
||||||
|
if (update.CallbackQuery?.Data is { } data &&
|
||||||
|
data == WizardCallbackData.Create())
|
||||||
|
{
|
||||||
|
await createSessionHandler.SubmitDraftAsync(draft, ct);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (update)
|
switch (update)
|
||||||
{
|
{
|
||||||
case { CallbackQuery: { } query }:
|
case { CallbackQuery: { } query }:
|
||||||
await HandleCallbackQueryAsync(query, ct);
|
await HandleCallbackQueryAsync(query, ct);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case { Message: { Text: { } text } message } when text.StartsWith('/'):
|
case { Message: { } message }:
|
||||||
await HandleCommandAsync(message, text, ct);
|
var commandText = GetCommandText(message);
|
||||||
break;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles router-level draft-control callbacks ("resume", "reset"). Returns true
|
||||||
|
/// if the update was consumed and the wizard should be skipped. The wizard still
|
||||||
|
/// owns "cancel" and "create".
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> TryHandleDraftControlCallbackAsync(
|
||||||
|
Update update, WizardDraft draft, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (update.CallbackQuery is not { Data: { } data, Message: { } cbMessage, From: { } cbFrom })
|
||||||
|
return false;
|
||||||
|
|
||||||
|
switch (data)
|
||||||
|
{
|
||||||
|
case WizardControlCallbacks.Resume:
|
||||||
|
// Re-render the current step of the existing draft. We answer the
|
||||||
|
// callback here because the wizard will not be called.
|
||||||
|
var (text, kb) = WizardStep.Render(draft, LoadPayload(draft));
|
||||||
|
await bot.EditMessageText(
|
||||||
|
chatId: cbMessage.Chat.Id,
|
||||||
|
messageId: cbMessage.MessageId,
|
||||||
|
text: text,
|
||||||
|
replyMarkup: kb,
|
||||||
|
cancellationToken: ct);
|
||||||
|
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case WizardControlCallbacks.Reset:
|
||||||
|
// Delete the existing draft and start a fresh one. The wizard is
|
||||||
|
// bypassed entirely because the active draft is now gone.
|
||||||
|
await drafts.DeleteAsync(draft.Id, ct);
|
||||||
|
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
|
||||||
|
var newDraft = await createSessionHandler.StartWizardAsync(
|
||||||
|
SyntheticStartMessage(cbMessage.Chat.Id, cbMessage.MessageThreadId, cbFrom.Id), ct);
|
||||||
|
if (newDraft is null)
|
||||||
|
{
|
||||||
|
// Race: another wizard just started for the same owner. The
|
||||||
|
// user can simply re-run /newsession. We don't loop.
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: cbMessage.Chat.Id,
|
||||||
|
text: "Не удалось начать заново — попробуйте ещё раз через /newsession.",
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a synthetic <see cref="Message"/> carrying just the fields
|
||||||
|
/// <see cref="CreateSessionHandler.StartWizardAsync"/> reads (chat, thread, from).
|
||||||
|
/// </summary>
|
||||||
|
private static Message SyntheticStartMessage(long chatId, int? messageThreadId, long fromId) => new()
|
||||||
|
{
|
||||||
|
Chat = new Chat { Id = chatId },
|
||||||
|
MessageThreadId = messageThreadId,
|
||||||
|
From = new User { Id = fromId },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static WizardPayload LoadPayload(WizardDraft draft) =>
|
||||||
|
SharedWizard.LoadPayload(draft);
|
||||||
|
|
||||||
|
internal static string GetCommandText(Message message)
|
||||||
|
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
|
||||||
|
/// Returns false for updates that carry no usable origin (e.g. inline queries).
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out string ownerId)
|
||||||
|
{
|
||||||
|
chatId = 0;
|
||||||
|
messageThreadId = null;
|
||||||
|
ownerId = string.Empty;
|
||||||
|
|
||||||
|
switch (update)
|
||||||
|
{
|
||||||
|
case { Message: { From: not null, Chat: { } chat } msg }:
|
||||||
|
chatId = chat.Id;
|
||||||
|
messageThreadId = msg.MessageThreadId;
|
||||||
|
ownerId = msg.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
|
||||||
|
chatId = cbmChat.Id;
|
||||||
|
messageThreadId = cb.Message?.MessageThreadId;
|
||||||
|
ownerId = cb.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case { CallbackQuery: { From: not null } cb2 }:
|
||||||
|
// Callback arrived without a message (e.g. from a Mini App). No chat
|
||||||
|
// context → wizard cannot run on this update.
|
||||||
|
ownerId = cb2.From!.Id.ToString(CultureInfo.InvariantCulture);
|
||||||
|
return false;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
|
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (query.Data is not { } data || query.Message is not { } message)
|
if (query.Data is not { } data || query.Message is not { } message)
|
||||||
@@ -58,18 +209,22 @@ public sealed class UpdateRouter(
|
|||||||
|
|
||||||
var parts = data.Split(':', 3);
|
var parts = data.Split(':', 3);
|
||||||
var action = parts[0];
|
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))
|
if (action == "join_session" && parts.Length >= 2 && Guid.TryParse(parts[1], out var joinSessionId))
|
||||||
{
|
{
|
||||||
var command = new JoinSessionCommand(
|
var command = new JoinSessionCommand(
|
||||||
SessionId: joinSessionId,
|
SessionId: joinSessionId,
|
||||||
TelegramUserId: query.From.Id,
|
User: user,
|
||||||
DisplayName: query.From.FirstName + (string.IsNullOrEmpty(query.From.LastName) ? "" : $" {query.From.LastName}"),
|
InteractionId: query.Id,
|
||||||
TelegramUsername: query.From.Username,
|
Group: group,
|
||||||
CallbackQueryId: query.Id,
|
ScheduleMessage: scheduleMessage);
|
||||||
ChatId: message.Chat.Id,
|
|
||||||
MessageId: message.MessageId);
|
|
||||||
|
|
||||||
await joinSessionHandler.HandleAsync(command, ct);
|
await joinSessionHandler.HandleAsync(command, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -78,10 +233,10 @@ public sealed class UpdateRouter(
|
|||||||
{
|
{
|
||||||
var command = new LeaveSessionCommand(
|
var command = new LeaveSessionCommand(
|
||||||
SessionId: leaveSessionId,
|
SessionId: leaveSessionId,
|
||||||
TelegramUserId: query.From.Id,
|
User: user,
|
||||||
CallbackQueryId: query.Id,
|
InteractionId: query.Id,
|
||||||
ChatId: message.Chat.Id,
|
Group: group,
|
||||||
MessageId: message.MessageId);
|
ScheduleMessage: scheduleMessage);
|
||||||
|
|
||||||
await leaveSessionHandler.HandleAsync(command, ct);
|
await leaveSessionHandler.HandleAsync(command, ct);
|
||||||
return;
|
return;
|
||||||
@@ -94,6 +249,7 @@ public sealed class UpdateRouter(
|
|||||||
TelegramUserId: query.From.Id,
|
TelegramUserId: query.From.Id,
|
||||||
CallbackQueryId: query.Id,
|
CallbackQueryId: query.Id,
|
||||||
ChatId: message.Chat.Id,
|
ChatId: message.Chat.Id,
|
||||||
|
MessageThreadId: message.MessageThreadId,
|
||||||
MessageId: message.MessageId);
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
await cancelSessionHandler.HandleAsync(command, ct);
|
await cancelSessionHandler.HandleAsync(command, ct);
|
||||||
@@ -133,21 +289,17 @@ public sealed class UpdateRouter(
|
|||||||
TelegramUserId: query.From.Id,
|
TelegramUserId: query.From.Id,
|
||||||
CallbackQueryId: query.Id,
|
CallbackQueryId: query.Id,
|
||||||
ChatId: message.Chat.Id,
|
ChatId: message.Chat.Id,
|
||||||
|
MessageThreadId: message.MessageThreadId,
|
||||||
MessageId: message.MessageId);
|
MessageId: message.MessageId);
|
||||||
|
|
||||||
await initiateRescheduleHandler.HandleAsync(command, ct);
|
await initiateRescheduleHandler.HandleAsync(command, ct);
|
||||||
return;
|
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(
|
var command = new HandleRescheduleVoteCommand(
|
||||||
ProposalId: proposalId,
|
OptionId: optionId,
|
||||||
Vote: vote,
|
|
||||||
TelegramUserId: query.From.Id,
|
TelegramUserId: query.From.Id,
|
||||||
CallbackQueryId: query.Id,
|
CallbackQueryId: query.Id,
|
||||||
ChatId: message.Chat.Id,
|
ChatId: message.Chat.Id,
|
||||||
@@ -174,11 +326,11 @@ public sealed class UpdateRouter(
|
|||||||
|
|
||||||
var command = new HandleRsvpCommand(
|
var command = new HandleRsvpCommand(
|
||||||
SessionId: sessionId,
|
SessionId: sessionId,
|
||||||
TelegramUserId: query.From.Id,
|
User: user,
|
||||||
Status: status,
|
Status: status,
|
||||||
CallbackQueryId: query.Id,
|
InteractionId: query.Id,
|
||||||
ChatId: message.Chat.Id,
|
Group: group,
|
||||||
MessageId: message.MessageId);
|
ConfirmationMessage: scheduleMessage);
|
||||||
|
|
||||||
await rsvpHandler.HandleAsync(command, ct);
|
await rsvpHandler.HandleAsync(command, ct);
|
||||||
}
|
}
|
||||||
@@ -193,14 +345,11 @@ public sealed class UpdateRouter(
|
|||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
case "/start":
|
case "/start":
|
||||||
await bot.SendMessage(
|
await SendStartMessageAsync(message, ct);
|
||||||
chatId: message.Chat.Id,
|
|
||||||
text: "GM-Relay Bot ready. Use /help for commands.",
|
|
||||||
cancellationToken: ct);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "/newsession":
|
case "/newsession":
|
||||||
await createSessionHandler.HandleAsync(message, ct);
|
await HandleNewSessionCommandAsync(message, ct);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "/listsessions":
|
case "/listsessions":
|
||||||
@@ -222,8 +371,14 @@ public sealed class UpdateRouter(
|
|||||||
Время: 15.05.2026 19:30
|
Время: 15.05.2026 19:30
|
||||||
Мест: 4
|
Мест: 4
|
||||||
Ссылка: https://link
|
Ссылка: https://link
|
||||||
|
Картинка: https://cover
|
||||||
|
|
||||||
|
Для регулярного расписания можно указать одну дату:
|
||||||
|
Игр: 4
|
||||||
|
Интервал: 7
|
||||||
|
|
||||||
/listsessions — список предстоящих сессий
|
/listsessions — список предстоящих сессий
|
||||||
|
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
|
||||||
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
||||||
/help — эта справка
|
/help — эта справка
|
||||||
""",
|
""",
|
||||||
@@ -236,4 +391,74 @@ public sealed class UpdateRouter(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleNewSessionCommandAsync(Message message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Try to start a fresh wizard. StartWizardAsync returns null when a
|
||||||
|
// non-expired draft already exists for this (chat, thread, owner).
|
||||||
|
var draft = await createSessionHandler.StartWizardAsync(message, ct);
|
||||||
|
if (draft is not null)
|
||||||
|
{
|
||||||
|
// New draft was created and its first step has been rendered.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing draft. Look it up so we can describe the current step and offer
|
||||||
|
// a Continue / Start over / Cancel menu.
|
||||||
|
var existing = await createSessionHandler.TryResumeAsync(message, ct);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
// Race: the draft expired between the two calls (or the user lacks
|
||||||
|
// ownership metadata). Fall back to silently starting a new wizard.
|
||||||
|
await createSessionHandler.StartWizardAsync(message, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.SendMessage(
|
||||||
|
chatId: message.Chat.Id,
|
||||||
|
text: "У вас уже есть незавершённый мастер. Продолжить?",
|
||||||
|
replyMarkup: ContinueResetCancelKeyboard(),
|
||||||
|
cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InlineKeyboardMarkup ContinueResetCancelKeyboard() => new(new[]
|
||||||
|
{
|
||||||
|
// "Продолжить" re-renders the existing draft's current step (router-level).
|
||||||
|
// "Начать заново" deletes the existing draft and creates a fresh one (router-level).
|
||||||
|
// "Отмена" delegates to the wizard's normal cancel handler.
|
||||||
|
new[] { InlineKeyboardButton.WithCallbackData("➡️ Продолжить", WizardControlCallbacks.Resume) },
|
||||||
|
new[] { InlineKeyboardButton.WithCallbackData("🔁 Начать заново", WizardControlCallbacks.Reset) },
|
||||||
|
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Router-level callback data for the Continue / Start over / Cancel menu shown
|
||||||
|
/// when /newsession detects an existing wizard draft. Distinct from
|
||||||
|
/// <see cref="WizardCallbackData"/> which is parsed and consumed by the wizard itself.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WizardControlCallbacks
|
||||||
|
{
|
||||||
|
public const string Resume = "wizard:resume";
|
||||||
|
public const string Reset = "wizard:reset";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Public GM profiles for catalog and club trust pages.
|
||||||
|
|
||||||
|
CREATE TABLE master_profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
public_slug VARCHAR(120),
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
display_name VARCHAR(255) NOT NULL,
|
||||||
|
bio TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX ux_master_profiles_public_slug
|
||||||
|
ON master_profiles (lower(public_slug))
|
||||||
|
WHERE public_slug IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX ix_master_profiles_public
|
||||||
|
ON master_profiles (lower(public_slug))
|
||||||
|
WHERE is_public = true AND public_slug IS NOT NULL;
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
-- Completed adventure portfolio cards with linked sessions, masters, and moderated reviews.
|
||||||
|
|
||||||
|
CREATE TABLE portfolio_games (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
|
||||||
|
public_slug VARCHAR(160),
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
cover_storage_key TEXT,
|
||||||
|
system VARCHAR(50),
|
||||||
|
format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
|
||||||
|
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CHECK (
|
||||||
|
NOT is_public
|
||||||
|
OR (
|
||||||
|
public_slug IS NOT NULL
|
||||||
|
AND description IS NOT NULL
|
||||||
|
AND cover_storage_key IS NOT NULL
|
||||||
|
AND published_at IS NOT NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX ux_portfolio_games_public_slug
|
||||||
|
ON portfolio_games (lower(public_slug))
|
||||||
|
WHERE public_slug IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX ix_portfolio_games_group
|
||||||
|
ON portfolio_games (group_id, completed_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX ix_portfolio_games_public
|
||||||
|
ON portfolio_games (completed_at DESC)
|
||||||
|
WHERE is_public = true;
|
||||||
|
|
||||||
|
CREATE TABLE portfolio_game_sessions (
|
||||||
|
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||||
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (portfolio_game_id, session_id),
|
||||||
|
UNIQUE (session_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE portfolio_game_masters (
|
||||||
|
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||||
|
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (portfolio_game_id, player_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_portfolio_game_masters_player
|
||||||
|
ON portfolio_game_masters (player_id, portfolio_game_id);
|
||||||
|
|
||||||
|
CREATE FUNCTION lock_portfolio_publication_mutation()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_portfolio_games_lock_publication_mutation
|
||||||
|
BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation
|
||||||
|
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation
|
||||||
|
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation
|
||||||
|
BEFORE DELETE OR UPDATE OF scheduled_at ON sessions
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete
|
||||||
|
BEFORE DELETE ON game_groups
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete
|
||||||
|
BEFORE DELETE ON players
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION lock_portfolio_publication_mutation();
|
||||||
|
|
||||||
|
CREATE FUNCTION validate_public_portfolio_game_required_links()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
target_portfolio_game_id UUID;
|
||||||
|
target_portfolio_game_ids UUID[];
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||||
|
|
||||||
|
IF TG_TABLE_NAME = 'portfolio_games' THEN
|
||||||
|
target_portfolio_game_ids := ARRAY[NEW.id];
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
|
||||||
|
ELSIF TG_OP = 'INSERT' THEN
|
||||||
|
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
|
||||||
|
ELSE
|
||||||
|
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'portfolio publication validation requires read committed isolation'
|
||||||
|
USING ERRCODE = '0A000';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT pg.id
|
||||||
|
INTO target_portfolio_game_id
|
||||||
|
FROM portfolio_games pg
|
||||||
|
WHERE pg.id = ANY(target_portfolio_game_ids)
|
||||||
|
AND pg.is_public = true
|
||||||
|
AND (
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM portfolio_game_sessions pgs
|
||||||
|
WHERE pgs.portfolio_game_id = pg.id
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM portfolio_game_sessions pgs
|
||||||
|
JOIN sessions s ON s.id = pgs.session_id
|
||||||
|
WHERE pgs.portfolio_game_id = pg.id
|
||||||
|
AND s.scheduled_at >= now()
|
||||||
|
)
|
||||||
|
OR NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM portfolio_game_masters pgm
|
||||||
|
WHERE pgm.portfolio_game_id = pg.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF target_portfolio_game_id IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'published portfolio game % must have at least one linked session and at least one linked master',
|
||||||
|
target_portfolio_game_id
|
||||||
|
USING ERRCODE = '23514';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
final_scheduled_at TIMESTAMPTZ;
|
||||||
|
BEGIN
|
||||||
|
SELECT s.scheduled_at
|
||||||
|
INTO final_scheduled_at
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.id = NEW.id;
|
||||||
|
|
||||||
|
IF final_scheduled_at >= now() THEN
|
||||||
|
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'portfolio future reschedule requires read committed isolation'
|
||||||
|
USING ERRCODE = '0A000';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM pg.id
|
||||||
|
FROM portfolio_games pg
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM portfolio_game_sessions pgs
|
||||||
|
JOIN sessions s ON s.id = pgs.session_id
|
||||||
|
WHERE pgs.portfolio_game_id = pg.id
|
||||||
|
AND s.scheduled_at >= now()
|
||||||
|
)
|
||||||
|
ORDER BY pg.id
|
||||||
|
FOR UPDATE OF pg;
|
||||||
|
|
||||||
|
PERFORM pg_advisory_xact_lock(20260530, 108);
|
||||||
|
|
||||||
|
UPDATE portfolio_games pg
|
||||||
|
SET is_public = false,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE pg.is_public = true
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM portfolio_game_sessions pgs
|
||||||
|
JOIN sessions s ON s.id = pgs.session_id
|
||||||
|
WHERE pgs.portfolio_game_id = pg.id
|
||||||
|
AND s.scheduled_at >= now()
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
|
||||||
|
AFTER UPDATE OF scheduled_at ON sessions
|
||||||
|
DEFERRABLE INITIALLY DEFERRED
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
|
||||||
|
|
||||||
|
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
|
||||||
|
AFTER INSERT OR UPDATE OF is_public ON portfolio_games
|
||||||
|
DEFERRABLE INITIALLY DEFERRED
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||||
|
|
||||||
|
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
|
||||||
|
AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
|
||||||
|
DEFERRABLE INITIALLY DEFERRED
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||||
|
|
||||||
|
CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links
|
||||||
|
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters
|
||||||
|
DEFERRABLE INITIALLY DEFERRED
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
|
||||||
|
|
||||||
|
CREATE TABLE portfolio_game_reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
|
||||||
|
author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||||
|
author_display_name VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
publication_consent_at TIMESTAMPTZ NOT NULL,
|
||||||
|
moderation_status VARCHAR(20) NOT NULL DEFAULT 'Pending'
|
||||||
|
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')),
|
||||||
|
moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
|
||||||
|
moderated_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (portfolio_game_id, author_player_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_portfolio_game_reviews_author
|
||||||
|
ON portfolio_game_reviews (author_player_id);
|
||||||
|
|
||||||
|
CREATE INDEX ix_portfolio_game_reviews_moderator
|
||||||
|
ON portfolio_game_reviews (moderated_by_player_id)
|
||||||
|
WHERE moderated_by_player_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX ix_portfolio_game_reviews_public
|
||||||
|
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
||||||
|
WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX ix_portfolio_game_reviews_pending
|
||||||
|
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
|
||||||
|
WHERE moderation_status = 'Pending';
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- V030: Private club showcases. Adds club_memberships (member access control)
|
||||||
|
-- and replaces sessions.is_public with a 4-state publication_mode enum.
|
||||||
|
-- Backfills existing data: is_public=true → 'Both', is_public=false → 'None'.
|
||||||
|
-- portfolio_games gains the same enum (default 'Both' for pre-V030 rows).
|
||||||
|
|
||||||
|
-- 1. club_memberships
|
||||||
|
CREATE TABLE club_memberships (
|
||||||
|
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,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'Pending'
|
||||||
|
CHECK (status IN ('Pending', 'Active', 'Rejected', 'Left')),
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'Member'
|
||||||
|
CHECK (role IN ('Member')),
|
||||||
|
message TEXT,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
decided_at TIMESTAMPTZ,
|
||||||
|
decided_by UUID REFERENCES players(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only one Active row per (group, player).
|
||||||
|
-- Re-application after Rejected/Left creates a new row.
|
||||||
|
CREATE UNIQUE INDEX ux_club_memberships_one_active
|
||||||
|
ON club_memberships (group_id, player_id)
|
||||||
|
WHERE status = 'Active';
|
||||||
|
|
||||||
|
CREATE INDEX ix_club_memberships_group_status
|
||||||
|
ON club_memberships (group_id, status);
|
||||||
|
|
||||||
|
CREATE INDEX ix_club_memberships_player_status
|
||||||
|
ON club_memberships (player_id, status);
|
||||||
|
|
||||||
|
-- 2. sessions.publication_mode (replaces is_public)
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'None';
|
||||||
|
|
||||||
|
-- Backfill before constraint so existing data maps cleanly.
|
||||||
|
UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true;
|
||||||
|
UPDATE sessions SET publication_mode = 'None' WHERE is_public = false;
|
||||||
|
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD CONSTRAINT ck_sessions_publication_mode
|
||||||
|
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
|
||||||
|
|
||||||
|
ALTER TABLE sessions DROP COLUMN is_public;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS ix_sessions_public_schedule;
|
||||||
|
DROP INDEX IF EXISTS ix_sessions_showcase;
|
||||||
|
|
||||||
|
CREATE INDEX ix_sessions_public_schedule
|
||||||
|
ON sessions (group_id, scheduled_at)
|
||||||
|
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
|
||||||
|
|
||||||
|
CREATE INDEX ix_sessions_showcase
|
||||||
|
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||||
|
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
|
||||||
|
|
||||||
|
-- 3. portfolio_games.publication_mode
|
||||||
|
-- Existing rows in portfolio_games keep 'Both' to stay visible to anonymous visitors.
|
||||||
|
ALTER TABLE portfolio_games
|
||||||
|
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'Both'
|
||||||
|
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
|
||||||
|
|
||||||
|
CREATE INDEX ix_portfolio_games_showcase
|
||||||
|
ON portfolio_games (created_at DESC)
|
||||||
|
WHERE publication_mode IN ('Catalog', 'Both');
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- V031: Per-(chat, thread, owner) wizard drafts for the game-creation wizard (issue #111).
|
||||||
|
-- Stores in-progress wizard state in JSONB with a 24h TTL managed by WizardDraftCleanupService.
|
||||||
|
|
||||||
|
CREATE TABLE wizard_drafts (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
chat_id BIGINT NOT NULL,
|
||||||
|
message_thread_id INT,
|
||||||
|
owner_telegram_id BIGINT NOT NULL,
|
||||||
|
step TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
draft_message_id BIGINT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wizard_drafts_owner
|
||||||
|
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wizard_drafts_expires
|
||||||
|
ON wizard_drafts(expires_at);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- V032: Platform-neutral wizard drafts (issue #112).
|
||||||
|
-- Adds the platform discriminator and switches owner/chat/thread/message
|
||||||
|
-- columns from numeric to TEXT so the same table can hold both Telegram
|
||||||
|
-- ids (long) and Discord snowflakes (ulong). All conversions are safe:
|
||||||
|
-- the affected columns are nullable except chat_id/owner_telegram_id
|
||||||
|
-- which we cast via TEXT.
|
||||||
|
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ADD COLUMN platform TEXT NOT NULL DEFAULT 'Telegram';
|
||||||
|
|
||||||
|
-- Convert chat_id: BIGINT → TEXT. Existing rows hold Telegram chat ids
|
||||||
|
-- which convert losslessly to their decimal string form.
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ALTER COLUMN chat_id TYPE TEXT USING chat_id::TEXT;
|
||||||
|
|
||||||
|
-- Convert message_thread_id: INT (nullable) → TEXT (nullable).
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ALTER COLUMN message_thread_id TYPE TEXT USING message_thread_id::TEXT;
|
||||||
|
|
||||||
|
-- Convert draft_message_id: BIGINT (nullable) → TEXT (nullable).
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ALTER COLUMN draft_message_id TYPE TEXT USING draft_message_id::TEXT;
|
||||||
|
|
||||||
|
-- Rename owner_telegram_id → owner_id (now platform-agnostic) and
|
||||||
|
-- convert from BIGINT to TEXT.
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
RENAME COLUMN owner_telegram_id TO owner_id;
|
||||||
|
|
||||||
|
ALTER TABLE wizard_drafts
|
||||||
|
ALTER COLUMN owner_id TYPE TEXT USING owner_id::TEXT;
|
||||||
|
|
||||||
|
-- Replace the old owner lookup index with one that uses the new column
|
||||||
|
-- names and the platform discriminator.
|
||||||
|
DROP INDEX IF EXISTS idx_wizard_drafts_owner;
|
||||||
|
|
||||||
|
CREATE INDEX idx_wizard_drafts_owner
|
||||||
|
ON wizard_drafts(platform, owner_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wizard_drafts_platform
|
||||||
|
ON wizard_drafts(platform);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN location_address TEXT;
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
|
||||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
|
||||||
using GmRelay.Bot.Features.Notifications;
|
using GmRelay.Bot.Features.Notifications;
|
||||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
|
||||||
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Bot.Infrastructure.Database;
|
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.Logging;
|
||||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
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.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
|
|
||||||
@@ -49,32 +56,62 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
|||||||
return new TelegramBotClient(token);
|
return new TelegramBotClient(token);
|
||||||
});
|
});
|
||||||
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
||||||
|
builder.Services.AddSingleton<IPlatformMessenger, TelegramPlatformMessenger>();
|
||||||
|
builder.Services.AddSingleton(new PlatformSchedulerOptions(PlatformKind.Telegram));
|
||||||
|
|
||||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||||
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
|
||||||
|
builder.Services.AddSingleton<PlatformDirectNotificationSender>();
|
||||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||||
|
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||||
builder.Services.AddSingleton<CreateSessionHandler>();
|
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>();
|
||||||
|
|
||||||
|
// Wizard services (issue #111)
|
||||||
|
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
|
||||||
|
builder.Services.AddSingleton<IWizardMessenger, TelegramWizardMessenger>();
|
||||||
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.Wizard.GameCreationWizard>();
|
||||||
|
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||||
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
|
||||||
builder.Services.AddSingleton<CancelSessionHandler>();
|
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.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.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<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
|
||||||
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
builder.Services.AddSingleton<InitiateRescheduleHandler>();
|
||||||
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
|
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
|
||||||
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
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 ──────────────────────────────────────────
|
// ── Telegram infrastructure ──────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<UpdateRouter>();
|
builder.Services.AddSingleton<UpdateRouter>();
|
||||||
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
|
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
|
||||||
|
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
||||||
builder.Services.AddHostedService<TelegramBotService>();
|
builder.Services.AddHostedService<TelegramBotService>();
|
||||||
|
|
||||||
|
// ── Clock and scheduling ──────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<ISystemClock, GmRelay.Bot.Infrastructure.Scheduling.SystemClock>();
|
||||||
|
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||||
|
|
||||||
// ── Session scheduler ────────────────────────────────────────────────
|
// ── Session scheduler ────────────────────────────────────────────────
|
||||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||||
|
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
|
||||||
|
builder.Services.AddHostedService<WizardDraftCleanupService>();
|
||||||
|
|
||||||
|
// ── Health check server ──────────────────────────────────────────────
|
||||||
|
builder.Services.AddHostedService<BotHealthCheckHostedService>();
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Telegram": {
|
"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,119 @@
|
|||||||
|
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 p.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);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"SELECT pg_advisory_xact_lock(20260530, 108)",
|
||||||
|
transaction: transaction);
|
||||||
|
_ = await connection.QuerySingleOrDefaultAsync<Guid?>(
|
||||||
|
"""
|
||||||
|
SELECT s.id
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
AND g.platform = 'Discord'
|
||||||
|
AND g.external_group_id = @GuildId
|
||||||
|
FOR UPDATE OF s
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, GuildId = guildId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE portfolio_games pg
|
||||||
|
SET is_public = false,
|
||||||
|
updated_at = now()
|
||||||
|
FROM portfolio_game_sessions pgs
|
||||||
|
JOIN sessions s ON s.id = pgs.session_id
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE pgs.portfolio_game_id = pg.id
|
||||||
|
AND s.id = @SessionId
|
||||||
|
AND g.platform = 'Discord'
|
||||||
|
AND g.external_group_id = @GuildId
|
||||||
|
AND pg.is_public = true
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, GuildId = guildId },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
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, format AS Format, location_address AS LocationAddress 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);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user