From 88256ae2d342ef66927ebd91bf1303e55c1b0e77 Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Fri, 25 Sep 2026 22:16:01 +0300 Subject: [PATCH] feat: add real telegram local mode --- .env.example | 4 ++ README.md | 26 +++++++ Taskfile.yml | 10 +++ compose/compose.yaml | 9 +++ compose/telegram.real.yaml | 6 ++ config/versions.env | 1 + .../0007-real-telegram-local-mode.md | 29 ++++++++ docs/development.md | 6 ++ scripts/check-compose.ps1 | 29 ++++++++ scripts/local-settings.ps1 | 2 +- scripts/start-telegram-real.ps1 | 72 +++++++++++++++++++ scripts/stop-telegram-real.ps1 | 28 ++++++++ 12 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 compose/telegram.real.yaml create mode 100644 docs/decisions/0007-real-telegram-local-mode.md create mode 100644 scripts/start-telegram-real.ps1 create mode 100644 scripts/stop-telegram-real.ps1 diff --git a/.env.example b/.env.example index feeff5b..5f26d53 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,10 @@ TELEGRAM_BOT_TOKEN=123456789:local_fake_bot_token TELEGRAM_WEBHOOK_SECRET=local_telegram_webhook_secret_32_chars TELEGRAM_TOKEN_KEY_BASE64=derive-from-local-webhook-secret +# Для ручной проверки настоящего бота раскомментируй и заполни только в локальном .env. +# TELEGRAM_REAL_BOT_TOKEN= +# TELEGRAM_REAL_WEBHOOK_SECRET= + AGENT_RUNTIME_PORT=18080 CHANNEL_GATEWAY_PORT=18084 MCP_GATEWAY_PORT=18083 diff --git a/README.md b/README.md index d550a3e..12f6230 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,32 @@ Compose project и всегда удаляет только созданные Если в `.env` оставлен placeholder `derive-from-local-webhook-secret`, команда запуска получает стабильный локальный encryption key из webhook secret только в памяти процесса. Для любого общего окружения `TELEGRAM_TOKEN_KEY_BASE64` должен приходить отдельным случайным секретом. + +### Настоящий Telegram локально + +Создай тестового бота через BotFather и добавь только в локальный `.env`: + +```dotenv +TELEGRAM_REAL_BOT_TOKEN= +TELEGRAM_REAL_WEBHOOK_SECRET=<случайная строка длиной от 32 символов> +``` + +Подключить настоящий Bot API через временный HTTPS URL: + +```powershell +task telegram:real:up +``` + +После проверки обязательно удали временный webhook и верни локальный fake API: + +```powershell +task telegram:real:down +``` + +Quick Tunnel предназначен только для ручной локальной проверки. Команда не печатает bot token и +не сохраняет его в Compose-файлах. После живого прогона в тесты переносятся только обезличенные формы +Telegram Update и Bot API response. + `TEST_LAB_PATH` по умолчанию указывает на соседний `../portable-agent-test-lab`; путь можно переопределить в `.env`. Channel Gateway доступен на `http://localhost:18084`, Agent Runtime — на `http://localhost:18080`, Action API — на diff --git a/Taskfile.yml b/Taskfile.yml index ba67b8f..f3858c8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -70,6 +70,16 @@ tasks: cmds: - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/service-local.ps1 -Action Logs -Service "{{.SERVICE}}" -Tail {{.TAIL}}' + telegram:real:up: + desc: Подключить локальный Telegram Adapter к настоящему боту через временный HTTPS tunnel + cmds: + - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/start-telegram-real.ps1' + + telegram:real:down: + desc: Удалить временный webhook и вернуть Telegram Adapter к fake API + cmds: + - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/stop-telegram-real.ps1' + status: desc: Показать состояние локального окружения cmds: diff --git a/compose/compose.yaml b/compose/compose.yaml index e298873..9878f1e 100644 --- a/compose/compose.yaml +++ b/compose/compose.yaml @@ -448,6 +448,15 @@ services: retries: 30 networks: [platform] + cloudflared: + profiles: [telegram-real] + image: ${CLOUDFLARED_IMAGE:?set CLOUDFLARED_IMAGE} + command: [tunnel, --no-autoupdate, --url, http://telegram-adapter:8080] + depends_on: + telegram-adapter: + condition: service_healthy + networks: [platform] + tempo: profiles: [observe] image: ${TEMPO_IMAGE:?set TEMPO_IMAGE} diff --git a/compose/telegram.real.yaml b/compose/telegram.real.yaml new file mode 100644 index 0000000..cbdd3de --- /dev/null +++ b/compose/telegram.real.yaml @@ -0,0 +1,6 @@ +services: + telegram-adapter: + environment: + TELEGRAM_API_URL: https://api.telegram.org + TELEGRAM_BOT_TOKEN: ${TELEGRAM_REAL_BOT_TOKEN:?set TELEGRAM_REAL_BOT_TOKEN} + TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_REAL_WEBHOOK_SECRET:?set TELEGRAM_REAL_WEBHOOK_SECRET} diff --git a/config/versions.env b/config/versions.env index 7639c6f..8ee205c 100644 --- a/config/versions.env +++ b/config/versions.env @@ -12,6 +12,7 @@ TEMPO_IMAGE=grafana/tempo:3.0.3 LOKI_IMAGE=grafana/loki:3.7.7 BUSYBOX_IMAGE=busybox:1.37.0 WIREMOCK_IMAGE=wiremock/wiremock:3.13.2 +CLOUDFLARED_IMAGE=cloudflare/cloudflared@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c CHANNEL_GATEWAY_IMAGE=ghcr.io/portable-agent/channel-gateway:65e8406b017e81ac6993818290f211a9b2c845aa AGENT_RUNTIME_IMAGE=ghcr.io/portable-agent/agent-runtime:4dd541e459d0bdbf889ddc1fbe68e1f7c3b47b89 ACTION_SERVICE_IMAGE=ghcr.io/portable-agent/action-service:8d449ab990001e31cd42a7655a16c5716c5d6f20 diff --git a/docs/decisions/0007-real-telegram-local-mode.md b/docs/decisions/0007-real-telegram-local-mode.md new file mode 100644 index 0000000..3907def --- /dev/null +++ b/docs/decisions/0007-real-telegram-local-mode.md @@ -0,0 +1,29 @@ +# ADR 0007: временный HTTPS tunnel для настоящего Telegram + +Статус: принято. + +## Контекст + +Telegram отправляет webhook только на публичный HTTPS URL. Локальный Adapter доступен лишь внутри +Docker Desktop, а открывать порт роутера или хранить постоянный внешний tunnel для ручной проверки +небезопасно. При этом fake API нужно сверить с настоящим Bot API до расширения acceptance-тестов. + +## Решение + +Отдельная ручная команда `task telegram:real:up` запускает Cloudflare Quick Tunnel к локальному +Telegram Adapter, проверяет bot token методом `getMe`, регистрирует webhook с `secret_token` и +проверяет результат через `getWebhookInfo`. Образ `cloudflared` закреплён digest. + +Quick Tunnel используется только локально и не входит в обычный CI или production. Bot token и +webhook secret читаются только из игнорируемого `.env`. Команда `task telegram:real:down` сначала +удаляет внешний webhook, затем возвращает Adapter к fake Telegram API. + +После живого прогона сохраняются только обезличенные формы Bot API. Токены, chat id, user id, +одноразовые коды и содержимое личных сообщений в fixture и логи не попадают. + +## Последствия + +- настоящий бот проверяется без публичного сервера и аккаунта Cloudflare; +- случайный URL меняется при каждом запуске и не подходит для постоянного окружения; +- временный tunnel зависит от внешней сети и поэтому намеренно исключён из CI; +- fake Telegram уточняется по фактическому контракту после ручной проверки. diff --git a/docs/development.md b/docs/development.md index 36b9d4e..539f317 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,6 +36,12 @@ task service:up SERVICE=telegram-adapter Она поднимет PostgreSQL, Keycloak, Channel Gateway и локальный fake Telegram API. Настоящий Telegram bot token для разработки и CI не требуется. +Когда нужно проверить настоящий Telegram, добавь `TELEGRAM_REAL_BOT_TOKEN` и +`TELEGRAM_REAL_WEBHOOK_SECRET` в игнорируемый `.env`, затем выполни `task telegram:real:up`. +Команда поднимет временный HTTPS tunnel, проверит bot token и зарегистрирует защищённый webhook. +После ручного сценария выполни `task telegram:real:down`: внешний webhook будет удалён до возврата +адаптера к fake API. + `task test:e2e` поднимает полный локальный срез и ждёт healthchecks. Затем `deploy` вызывает `task test:e2e` в соседнем репозитории `test-lab`, который владеет black-box сценарием от текста до сохранённого события. Если репозитории лежат не рядом, задай `TEST_LAB_PATH` в локальном `.env`. diff --git a/scripts/check-compose.ps1 b/scripts/check-compose.ps1 index 9d44208..963fe0a 100644 --- a/scripts/check-compose.ps1 +++ b/scripts/check-compose.ps1 @@ -254,6 +254,9 @@ foreach ($required in @("TEST_LAB_PATH", "portable-agent-realm.json", "CALENDAR_ } } $versions = Get-Content -Raw -LiteralPath "config/versions.env" +if ($versions -notmatch '(?m)^CLOUDFLARED_IMAGE=cloudflare/cloudflared@sha256:[0-9a-f]{64}\r?$') { + throw "Cloudflared image должен быть закреплён digest." +} if ($versions -notmatch '(?m)^AGENT_RUNTIME_IMAGE=ghcr\.io/portable-agent/agent-runtime:[0-9a-f]{40}\r?$') { throw "Agent Runtime image должен быть закреплён полным Git SHA." } @@ -266,6 +269,32 @@ if ($versions -notmatch '(?m)^TELEGRAM_ADAPTER_IMAGE=ghcr\.io/portable-agent/tel if ($versions -notmatch '(?m)^TEST_LAB_REF=[0-9a-f]{40}\r?$') { throw "Test Lab должен быть закреплён полным Git SHA." } +$realTelegramPath = "compose/telegram.real.yaml" +if (-not (Test-Path -LiteralPath $realTelegramPath)) { + throw "Нет отдельного Compose override для настоящего Telegram." +} +$realTelegram = Get-Content -Raw -LiteralPath $realTelegramPath +foreach ($required in @("TELEGRAM_REAL_BOT_TOKEN", "TELEGRAM_REAL_WEBHOOK_SECRET", "https://api.telegram.org")) { + if ($realTelegram -notmatch [regex]::Escape($required)) { + throw "Real Telegram override не содержит $required." + } +} +$realTelegramScript = Get-Content -Raw -LiteralPath "scripts/start-telegram-real.ps1" +foreach ($required in @("getMe", "setWebhook", "getWebhookInfo", "trycloudflare", "allowed_updates", "callback_query")) { + if ($realTelegramScript -notmatch [regex]::Escape($required)) { + throw "Запуск настоящего Telegram не содержит $required." + } +} +$stopTelegramScript = Get-Content -Raw -LiteralPath "scripts/stop-telegram-real.ps1" +if ($stopTelegramScript -notmatch 'deleteWebhook') { + throw "Остановка настоящего Telegram должна удалить временный webhook." +} +$taskfile = Get-Content -Raw -LiteralPath "Taskfile.yml" +foreach ($taskName in @("telegram:real:up", "telegram:real:down")) { + if ($taskfile -notmatch "(?m)^ $([regex]::Escape($taskName)):") { + throw "В Taskfile нет команды $taskName." + } +} $appWorkflowPath = ".github/workflows/app-smoke.yml" if (-not (Test-Path -LiteralPath $appWorkflowPath)) { throw "Нет CI-проверки полного Compose-среза." diff --git a/scripts/local-settings.ps1 b/scripts/local-settings.ps1 index 0c30b94..a00562a 100644 --- a/scripts/local-settings.ps1 +++ b/scripts/local-settings.ps1 @@ -5,7 +5,7 @@ function Get-LocalSetting([string]$Name) { $line = Get-Content -LiteralPath $path | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -Last 1 if ($line) { $value = $line.Substring($line.IndexOf('=') + 1) } } - if (-not $value) { throw "Не задан $Name." } + if (-not $value) { throw "Set $Name in the environment or local .env file." } return $value } diff --git a/scripts/start-telegram-real.ps1 b/scripts/start-telegram-real.ps1 new file mode 100644 index 0000000..9f63caa --- /dev/null +++ b/scripts/start-telegram-real.ps1 @@ -0,0 +1,72 @@ +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/local-settings.ps1" + +$token = Get-LocalSetting "TELEGRAM_REAL_BOT_TOKEN" +$webhookSecret = Get-LocalSetting "TELEGRAM_REAL_WEBHOOK_SECRET" +if ($token -notmatch '^\d+:[A-Za-z0-9_-]{20,}$') { + throw "Set a valid TELEGRAM_REAL_BOT_TOKEN from BotFather in local .env." +} +if ($webhookSecret -notmatch '^[A-Za-z0-9_-]{32,256}$') { + throw "TELEGRAM_REAL_WEBHOOK_SECRET must contain 32-256 characters: A-Z, a-z, 0-9, _ or -." +} + +$env:TELEGRAM_REAL_BOT_TOKEN = $token +$env:TELEGRAM_REAL_WEBHOOK_SECRET = $webhookSecret +Initialize-LocalTelegramKey + +& "$PSScriptRoot/start-local.ps1" -Apps +if ($LASTEXITCODE -ne 0) { throw "The local stack did not start." } + +$envFiles = @("--env-file", ".env.example", "--env-file", "config/versions.env") +if (Test-Path .env) { $envFiles += @("--env-file", ".env") } +$composeFiles = @("-f", "compose/compose.yaml") +$runningOnWindows = $PSVersionTable.PSEdition -eq "Desktop" -or $IsWindows +if ($runningOnWindows) { $composeFiles += @("-f", "compose/windows.local.yaml") } +$composeFiles += @("-f", "compose/apps.local.yaml", "-f", "compose/telegram.real.yaml") + +& docker compose @envFiles @composeFiles --profile core --profile apps --profile telegram-real ` + up -d --build --wait --force-recreate telegram-adapter cloudflared +if ($LASTEXITCODE -ne 0) { throw "Real Telegram mode did not start." } + +$tunnelUrl = $null +for ($attempt = 0; $attempt -lt 30 -and -not $tunnelUrl; $attempt += 1) { + Start-Sleep -Seconds 1 + $logs = & docker compose @envFiles @composeFiles --profile telegram-real logs --no-color cloudflared 2>&1 + $match = [regex]::Match(($logs -join "`n"), 'https://[a-z0-9-]+\.trycloudflare\.com') + if ($match.Success) { $tunnelUrl = $match.Value } +} +if (-not $tunnelUrl) { throw "Cloudflare Quick Tunnel did not provide an HTTPS URL." } + +$ready = $false +for ($attempt = 0; $attempt -lt 20 -and -not $ready; $attempt += 1) { + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "$tunnelUrl/health/ready" -TimeoutSec 5 + $ready = $response.StatusCode -eq 200 + } catch { + Start-Sleep -Seconds 1 + } +} +if (-not $ready) { throw "The public webhook URL cannot reach Telegram Adapter." } + +$apiUrl = "https://api.telegram.org/bot$token" +try { + $bot = Invoke-RestMethod -Method Post -Uri "$apiUrl/getMe" + if (-not $bot.ok -or -not $bot.result.username) { throw "invalid getMe" } + $hook = Invoke-RestMethod -Method Post -Uri "$apiUrl/setWebhook" -ContentType "application/json" -Body (@{ + url = "$tunnelUrl/webhooks/telegram" + secret_token = $webhookSecret + allowed_updates = @("message", "callback_query") + drop_pending_updates = $false + } | ConvertTo-Json) + if (-not $hook.ok) { throw "invalid setWebhook" } + $info = Invoke-RestMethod -Method Post -Uri "$apiUrl/getWebhookInfo" + if (-not $info.ok -or $info.result.url -ne "$tunnelUrl/webhooks/telegram") { + throw "invalid getWebhookInfo" + } +} catch { + throw "Telegram Bot API rejected the bot token or temporary webhook. Secrets were not printed." +} + +Write-Host "Real Telegram connected: @$($bot.result.username)" +Write-Host "Webhook: $tunnelUrl/webhooks/telegram" +Write-Host "Return to the fake API with: task telegram:real:down" diff --git a/scripts/stop-telegram-real.ps1 b/scripts/stop-telegram-real.ps1 new file mode 100644 index 0000000..da55ca6 --- /dev/null +++ b/scripts/stop-telegram-real.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = "Stop" +. "$PSScriptRoot/local-settings.ps1" + +$token = Get-LocalSetting "TELEGRAM_REAL_BOT_TOKEN" +if ($token -notmatch '^\d+:[A-Za-z0-9_-]{20,}$') { + throw "Set a valid TELEGRAM_REAL_BOT_TOKEN from BotFather in local .env." +} + +try { + $result = Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$token/deleteWebhook" ` + -ContentType "application/json" -Body '{"drop_pending_updates":false}' + if (-not $result.ok) { throw "invalid deleteWebhook" } +} catch { + throw "Telegram Bot API did not remove the temporary webhook. The secret was not printed." +} + +$envFiles = @("--env-file", ".env.example", "--env-file", "config/versions.env") +if (Test-Path .env) { $envFiles += @("--env-file", ".env") } +$composeFiles = @("-f", "compose/compose.yaml") +$runningOnWindows = $PSVersionTable.PSEdition -eq "Desktop" -or $IsWindows +if ($runningOnWindows) { $composeFiles += @("-f", "compose/windows.local.yaml") } +$composeFiles += @("-f", "compose/apps.local.yaml") +& docker compose @envFiles @composeFiles --profile telegram-real stop cloudflared telegram-adapter +if ($LASTEXITCODE -ne 0) { throw "Failed to stop the temporary Telegram webhook." } + +& "$PSScriptRoot/service-local.ps1" -Action Restart -Service telegram-adapter +if ($LASTEXITCODE -ne 0) { throw "Failed to return Telegram Adapter to the fake API." } +Write-Host "The temporary webhook was removed. Telegram Adapter uses the fake API again."