Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<token BotFather>
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 — на
Expand Down
10 changes: 10 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions compose/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
6 changes: 6 additions & 0 deletions compose/telegram.real.yaml
Original file line number Diff line number Diff line change
@@ -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}
1 change: 1 addition & 0 deletions config/versions.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/decisions/0007-real-telegram-local-mode.md
Original file line number Diff line number Diff line change
@@ -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 уточняется по фактическому контракту после ручной проверки.
6 changes: 6 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
29 changes: 29 additions & 0 deletions scripts/check-compose.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand All @@ -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-среза."
Expand Down
2 changes: 1 addition & 1 deletion scripts/local-settings.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
72 changes: 72 additions & 0 deletions scripts/start-telegram-real.ps1
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 28 additions & 0 deletions scripts/stop-telegram-real.ps1
Original file line number Diff line number Diff line change
@@ -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."
Loading