From 214ca5de1ac32467c8acd880a451fea8ec6ec524 Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Fri, 25 Sep 2026 01:52:38 +0300 Subject: [PATCH] feat: add telegram adapter to local stack --- .env.example | 10 +++ .github/workflows/app-smoke.yml | 10 +++ README.md | 16 +++-- compose/apps.local.yaml | 6 ++ compose/compose.yaml | 53 ++++++++++++++ compose/keycloak/portable-agent-realm.json | 84 ++++++++++++++++++++++ compose/postgres/init/01-users.sh | 13 +++- compose/telegram/mappings/bot-api.json | 16 +++++ config/versions.env | 2 + docs/architecture.md | 17 +++-- docs/decisions/0006-fake-telegram-api.md | 26 +++++++ docs/development.md | 11 ++- docs/index.md | 5 +- scripts/check-compose.ps1 | 52 ++++++++++++-- scripts/check-keycloak.ps1 | 13 ++++ scripts/local-settings.ps1 | 24 +++++++ scripts/service-local.ps1 | 4 +- scripts/start-local.ps1 | 16 ++--- 18 files changed, 348 insertions(+), 30 deletions(-) create mode 100644 compose/telegram/mappings/bot-api.json create mode 100644 docs/decisions/0006-fake-telegram-api.md create mode 100644 scripts/local-settings.ps1 diff --git a/.env.example b/.env.example index 170eb41..feeff5b 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,15 @@ CONVERSATION_DB_USER=conversations CONVERSATION_DB_PASSWORD=local_conversations_change_me CONVERSATION_SERVICE_PORT=18085 +TELEGRAM_DB_USER=telegram +TELEGRAM_DB_PASSWORD=local_telegram_db_change_me +TELEGRAM_ADAPTER_CLIENT_SECRET=local_telegram_client_change_me +TELEGRAM_ADAPTER_PORT=18086 +TELEGRAM_FAKE_PORT=18087 +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 + AGENT_RUNTIME_PORT=18080 CHANNEL_GATEWAY_PORT=18084 MCP_GATEWAY_PORT=18083 @@ -28,6 +37,7 @@ ACTION_SERVICE_CONTEXT=../../portable-agent-action-service MCP_GATEWAY_CONTEXT=../../portable-agent-mcp-gateway CALENDAR_MCP_CONTEXT=../../portable-agent-calendar-mcp CONVERSATION_SERVICE_CONTEXT=../../portable-agent-conversation-service +TELEGRAM_ADAPTER_CONTEXT=../../portable-agent-telegram-adapter TEST_LAB_PATH=../portable-agent-test-lab KEYCLOAK_DB_USER=keycloak diff --git a/.github/workflows/app-smoke.yml b/.github/workflows/app-smoke.yml index 0c931cb..810e747 100644 --- a/.github/workflows/app-smoke.yml +++ b/.github/workflows/app-smoke.yml @@ -12,6 +12,7 @@ on: - "scripts/run-test-lab.ps1" - "scripts/check-compose.ps1" - "scripts/check-keycloak.ps1" + - "scripts/local-settings.ps1" - "scripts/start-local.ps1" - "scripts/service-local.ps1" - "scripts/stop-local.ps1" @@ -32,6 +33,7 @@ jobs: CONVERSATION_SERVICE_CONTEXT: ${{ github.workspace }}/repos/conversation-service MCP_GATEWAY_CONTEXT: ${{ github.workspace }}/repos/mcp-gateway CALENDAR_MCP_CONTEXT: ${{ github.workspace }}/repos/calendar-mcp + TELEGRAM_ADAPTER_CONTEXT: ${{ github.workspace }}/repos/telegram-adapter TEST_LAB_PATH: ${{ github.workspace }}/repos/test-lab steps: - name: Получить deploy @@ -48,6 +50,7 @@ jobs: "conversation=$($versions.CONVERSATION_SERVICE_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT "gateway=$($versions.MCP_GATEWAY_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT "calendar=$($versions.CALENDAR_MCP_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT + "telegram=$($versions.TELEGRAM_ADAPTER_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT "test_lab=$($versions.TEST_LAB_REF)" >> $env:GITHUB_OUTPUT - name: Получить Channel Gateway @@ -92,6 +95,13 @@ jobs: ref: ${{ steps.versions.outputs.calendar }} path: repos/calendar-mcp + - name: Получить Telegram Adapter + uses: actions/checkout@v7 + with: + repository: portable-agent/telegram-adapter + ref: ${{ steps.versions.outputs.telegram }} + path: repos/telegram-adapter + - name: Получить Test Lab uses: actions/checkout@v7 with: diff --git a/README.md b/README.md index 703c2f7..d550a3e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Платформа разработки включает: - Compose-профиль `core`: PostgreSQL, Redpanda, Keycloak, Temporal и OPA; -- Compose-профиль `apps`: Channel Gateway, Conversation Service, Agent Runtime, Action Service, MCP Gateway и Calendar MCP; +- Compose-профиль `apps`: Telegram Adapter, fake Telegram API, Channel Gateway, Conversation Service, Agent Runtime, Action Service, MCP Gateway и Calendar MCP; - Compose-профиль `observe`: OpenTelemetry, Prometheus, Grafana, Tempo и Loki; - безопасный общий chart `charts/service`; - изолированный PR preview namespace с quota, limits и default-deny сетью; @@ -74,8 +74,12 @@ Compose project и всегда удаляет только созданные `-Apps` собирает образы из соседних локальных репозиториев через `compose/apps.local.yaml`. Пути можно переопределить переменными `CHANNEL_GATEWAY_CONTEXT`, `AGENT_RUNTIME_CONTEXT`, `ACTION_SERVICE_CONTEXT`, -`CONVERSATION_SERVICE_CONTEXT`, `MCP_GATEWAY_CONTEXT` и `CALENDAR_MCP_CONTEXT`; вход в GHCR для локального -запуска не нужен. +`CONVERSATION_SERVICE_CONTEXT`, `MCP_GATEWAY_CONTEXT`, `CALENDAR_MCP_CONTEXT` и +`TELEGRAM_ADAPTER_CONTEXT`; вход в GHCR для локального запуска не нужен. Fake Telegram API отвечает +локально и не требует настоящего bot token. +Если в `.env` оставлен placeholder `derive-from-local-webhook-secret`, команда запуска получает +стабильный локальный encryption key из webhook secret только в памяти процесса. Для любого общего +окружения `TELEGRAM_TOKEN_KEY_BASE64` должен приходить отдельным случайным секретом. `TEST_LAB_PATH` по умолчанию указывает на соседний `../portable-agent-test-lab`; путь можно переопределить в `.env`. Channel Gateway доступен на `http://localhost:18084`, Agent Runtime — на `http://localhost:18080`, Action API — на @@ -93,8 +97,10 @@ manager, а не из Git. `portable-agent-local` и пользователь `local-user` с паролем `local-user-change-me` существуют только в Compose fixture. Отдельный confidential client `action-service` выдаёт worker служебный токен с `tenant_id`, audience `mcp-gateway` и `calendar-mcp`, scopes `mcp:call` и `calendar:write`. Значения -локального секрета и tenant приходят из `.env`, а не зашиты в image. PostgreSQL создаёт отдельную БД -`actions` для Action Service и `conversations` для Conversation Service. +локального секрета и tenant приходят из `.env`, а не зашиты в image. Отдельный confidential client +`telegram-adapter` включает OAuth Device Authorization Grant и выдаёт пользовательский токен с теми +же `tenant_id` и audience backend-пути. PostgreSQL создаёт отдельные БД `actions`, `conversations` и +`telegram_adapter`. Проверочный API Calendar MCP включён только в локальном профиле `apps`, привязан к loopback-порту и защищён `CALENDAR_TEST_API_KEY`. Хранилище fake-calendar пока находится в памяти: перезапуск контейнера diff --git a/compose/apps.local.yaml b/compose/apps.local.yaml index 245dcf6..2fd9bb5 100644 --- a/compose/apps.local.yaml +++ b/compose/apps.local.yaml @@ -34,3 +34,9 @@ services: build: context: ${CALENDAR_MCP_CONTEXT:-../../portable-agent-calendar-mcp} pull_policy: build + + telegram-adapter: + image: portable-agent/telegram-adapter:local + build: + context: ${TELEGRAM_ADAPTER_CONTEXT:-../../portable-agent-telegram-adapter} + pull_policy: build diff --git a/compose/compose.yaml b/compose/compose.yaml index 800bc0e..e298873 100644 --- a/compose/compose.yaml +++ b/compose/compose.yaml @@ -16,6 +16,8 @@ services: ACTION_DB_PASSWORD: ${ACTION_DB_PASSWORD:?set ACTION_DB_PASSWORD} CONVERSATION_DB_USER: ${CONVERSATION_DB_USER:?set CONVERSATION_DB_USER} CONVERSATION_DB_PASSWORD: ${CONVERSATION_DB_PASSWORD:?set CONVERSATION_DB_PASSWORD} + TELEGRAM_DB_USER: ${TELEGRAM_DB_USER:?set TELEGRAM_DB_USER} + TELEGRAM_DB_PASSWORD: ${TELEGRAM_DB_PASSWORD:?set TELEGRAM_DB_PASSWORD} ports: - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" volumes: @@ -73,6 +75,7 @@ services: KC_HOSTNAME: http://localhost:${KEYCLOAK_PORT:-8081} ACTION_SERVICE_CLIENT_SECRET: ${ACTION_SERVICE_CLIENT_SECRET:?set ACTION_SERVICE_CLIENT_SECRET} ACTION_SERVICE_TENANT_ID: ${ACTION_SERVICE_TENANT_ID:?set ACTION_SERVICE_TENANT_ID} + TELEGRAM_ADAPTER_CLIENT_SECRET: ${TELEGRAM_ADAPTER_CLIENT_SECRET:?set TELEGRAM_ADAPTER_CLIENT_SECRET} depends_on: postgres: condition: service_healthy @@ -109,6 +112,8 @@ services: ACTION_DB_PASSWORD: ${ACTION_DB_PASSWORD:?set ACTION_DB_PASSWORD} CONVERSATION_DB_USER: ${CONVERSATION_DB_USER:?set CONVERSATION_DB_USER} CONVERSATION_DB_PASSWORD: ${CONVERSATION_DB_PASSWORD:?set CONVERSATION_DB_PASSWORD} + TELEGRAM_DB_USER: ${TELEGRAM_DB_USER:?set TELEGRAM_DB_USER} + TELEGRAM_DB_PASSWORD: ${TELEGRAM_DB_PASSWORD:?set TELEGRAM_DB_PASSWORD} volumes: - ./postgres:/scripts:ro depends_on: @@ -395,6 +400,54 @@ services: start_period: 20s networks: [platform] + fake-telegram: + profiles: [apps] + image: ${WIREMOCK_IMAGE:?set WIREMOCK_IMAGE} + command: [--disable-banner] + volumes: + - ./telegram:/home/wiremock:ro + ports: + - "127.0.0.1:${TELEGRAM_FAKE_PORT:-18087}:8080" + networks: [platform] + + telegram-adapter: + profiles: [apps] + image: ${TELEGRAM_ADAPTER_IMAGE:?set TELEGRAM_ADAPTER_IMAGE} + environment: + HOST: 0.0.0.0 + PORT: 8080 + TELEGRAM_API_URL: http://fake-telegram:8080 + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN} + TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?set TELEGRAM_WEBHOOK_SECRET} + KEYCLOAK_URL: http://keycloak:8080 + KEYCLOAK_REALM: portable-agent + KEYCLOAK_CLIENT_ID: telegram-adapter + KEYCLOAK_CLIENT_SECRET: ${TELEGRAM_ADAPTER_CLIENT_SECRET:?set TELEGRAM_ADAPTER_CLIENT_SECRET} + DATABASE_URL: postgres://${TELEGRAM_DB_USER:?set TELEGRAM_DB_USER}:${TELEGRAM_DB_PASSWORD:?set TELEGRAM_DB_PASSWORD}@postgres:5432/telegram_adapter + CHANNEL_GATEWAY_URL: http://channel-gateway:8080 + CHANNEL_GATEWAY_TIMEOUT_MS: 10000 + LINK_POLL_MS: 1000 + DEFAULT_LOCALE: ru-RU + DEFAULT_TIME_ZONE: Europe/Moscow + TOKEN_KEY_BASE64: ${TELEGRAM_TOKEN_KEY_BASE64:?set TELEGRAM_TOKEN_KEY_BASE64} + depends_on: + postgres: + condition: service_healthy + keycloak: + condition: service_healthy + channel-gateway: + condition: service_healthy + fake-telegram: + condition: service_started + ports: + - "127.0.0.1:${TELEGRAM_ADAPTER_PORT:-18086}:8080" + healthcheck: + test: [CMD, node, -e, "fetch('http://localhost:8080/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 5s + timeout: 3s + retries: 30 + networks: [platform] + tempo: profiles: [observe] image: ${TEMPO_IMAGE:?set TEMPO_IMAGE} diff --git a/compose/keycloak/portable-agent-realm.json b/compose/keycloak/portable-agent-realm.json index 6821878..8960089 100644 --- a/compose/keycloak/portable-agent-realm.json +++ b/compose/keycloak/portable-agent-realm.json @@ -129,6 +129,90 @@ } ] }, + { + "clientId": "telegram-adapter", + "name": "Telegram Adapter", + "enabled": true, + "publicClient": false, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "clientAuthenticatorType": "client-secret", + "secret": "${TELEGRAM_ADAPTER_CLIENT_SECRET}", + "protocol": "openid-connect", + "attributes": { + "oauth2.device.authorization.grant.enabled": "true", + "use.refresh.tokens": "true" + }, + "defaultClientScopes": ["basic"], + "optionalClientScopes": ["offline_access"], + "protocolMappers": [ + { + "name": "tenant-id", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "tenant_id", + "claim.name": "tenant_id", + "jsonType.label": "String", + "multivalued": "false", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "channel-gateway-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "channel-gateway", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "agent-runtime-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "agent-runtime", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "action-service-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "action-service", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "conversation-service-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "conversation-service", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, { "clientId": "portable-agent-local", "name": "Portable Agent Local Test", diff --git a/compose/postgres/init/01-users.sh b/compose/postgres/init/01-users.sh index e677925..066faae 100644 --- a/compose/postgres/init/01-users.sh +++ b/compose/postgres/init/01-users.sh @@ -10,6 +10,8 @@ set -eu : "${ACTION_DB_PASSWORD:?ACTION_DB_PASSWORD is required}" : "${CONVERSATION_DB_USER:?CONVERSATION_DB_USER is required}" : "${CONVERSATION_DB_PASSWORD:?CONVERSATION_DB_PASSWORD is required}" +: "${TELEGRAM_DB_USER:?TELEGRAM_DB_USER is required}" +: "${TELEGRAM_DB_PASSWORD:?TELEGRAM_DB_PASSWORD is required}" psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ --set=keycloak_user="$KEYCLOAK_DB_USER" \ @@ -19,7 +21,9 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ --set=action_user="$ACTION_DB_USER" \ --set=action_password="$ACTION_DB_PASSWORD" \ --set=conversation_user="$CONVERSATION_DB_USER" \ - --set=conversation_password="$CONVERSATION_DB_PASSWORD" <<-'SQL' + --set=conversation_password="$CONVERSATION_DB_PASSWORD" \ + --set=telegram_user="$TELEGRAM_DB_USER" \ + --set=telegram_password="$TELEGRAM_DB_PASSWORD" <<-'SQL' SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'keycloak_user', :'keycloak_password') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'keycloak_user') \gexec SELECT format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'keycloak_user', :'keycloak_password') \gexec @@ -44,4 +48,11 @@ SELECT format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'conversation_user', :'co SELECT format('CREATE DATABASE conversations OWNER %I', :'conversation_user') WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'conversations') \gexec + +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'telegram_user', :'telegram_password') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'telegram_user') \gexec +SELECT format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'telegram_user', :'telegram_password') \gexec + +SELECT format('CREATE DATABASE telegram_adapter OWNER %I', :'telegram_user') +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'telegram_adapter') \gexec SQL diff --git a/compose/telegram/mappings/bot-api.json b/compose/telegram/mappings/bot-api.json new file mode 100644 index 0000000..c6166c4 --- /dev/null +++ b/compose/telegram/mappings/bot-api.json @@ -0,0 +1,16 @@ +{ + "request": { + "method": "POST", + "urlPathPattern": "/bot[^/]+/(sendMessage|answerCallbackQuery)" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "ok": true, + "result": true + } + } +} diff --git a/config/versions.env b/config/versions.env index 7c5345f..7639c6f 100644 --- a/config/versions.env +++ b/config/versions.env @@ -11,10 +11,12 @@ GRAFANA_IMAGE=grafana/grafana:13.2.0 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 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 CONVERSATION_SERVICE_IMAGE=ghcr.io/portable-agent/conversation-service:fb069a35c6a8406f88f665e8197ebfbc3564008a MCP_GATEWAY_IMAGE=ghcr.io/portable-agent/mcp-gateway:0a3884d52ad1156ed173a4ecf9182ea3a0d2e686 CALENDAR_MCP_IMAGE=ghcr.io/portable-agent/calendar-mcp:bbab64774eab482c9d3543c7b8220b4cb42df56e +TELEGRAM_ADAPTER_IMAGE=ghcr.io/portable-agent/telegram-adapter:232119576688e36a609546267b483da19d3ccc38 TEST_LAB_REF=26a8842c1b403bf50e6c67fdb7eaa7ae83d36d48 diff --git a/docs/architecture.md b/docs/architecture.md index 7ef79c8..d861ed8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,6 +30,7 @@ services/catalog.json -> environments//services//values.yaml ```mermaid sequenceDiagram participant User as Пользователь + participant Telegram as Telegram Adapter participant Channel as Channel Gateway participant Conversation as Conversation Service participant Agent as Agent Runtime @@ -38,9 +39,13 @@ sequenceDiagram participant Gateway as MCP Gateway participant Calendar as Calendar MCP - User->>Keycloak: логин - Keycloak-->>User: tenant + audience channel, conversation, agent и action - User->>Channel: текст + безопасный context + JWT + User->>Telegram: /link + Telegram->>Keycloak: начать Device Flow + Keycloak-->>User: ссылка и одноразовый код + User->>Keycloak: логин в браузере + Keycloak-->>Telegram: refresh token + tenant + audience + User->>Telegram: текст + Telegram->>Channel: текст + безопасный context + JWT Channel->>Channel: JWT + нормализация канала Channel->>Conversation: сообщение + тот же JWT Conversation->>Conversation: сохранить сообщение @@ -49,8 +54,10 @@ sequenceDiagram Agent-->>Conversation: готовое предложение Conversation->>Action: создать действие Action-->>Conversation: actionId + payloadHash - Conversation-->>User: переносимый виджет подтверждения - User->>Channel: подтвердить виджет + Conversation-->>Telegram: переносимый виджет подтверждения + Telegram-->>User: inline-кнопки + User->>Telegram: подтвердить виджет + Telegram->>Channel: решение + JWT Channel->>Action: actionId + payloadHash + решение Action->>Action: JWT issuer + audience + tenant Action->>Keycloak: client_credentials diff --git a/docs/decisions/0006-fake-telegram-api.md b/docs/decisions/0006-fake-telegram-api.md new file mode 100644 index 0000000..5022fa6 --- /dev/null +++ b/docs/decisions/0006-fake-telegram-api.md @@ -0,0 +1,26 @@ +# ADR 0006: локальная заглушка Telegram Bot API + +Статус: принято. + +## Контекст + +Telegram Adapter нужно проверять в общем Compose до появления настоящего bot token. Настоящий Telegram +не подходит для CI: тест зависел бы от внешней сети, общего аккаунта и секрета. Самописный HTTP-сервис +добавил бы в `deploy` лишний код и поддержку ещё одного приложения. + +## Решение + +В локальном профиле `apps` запускается закреплённый образ WireMock. Он принимает только два исходящих +метода текущего среза: `sendMessage` и `answerCallbackQuery`, а также хранит журнал запросов для будущих +black-box проверок из `test-lab`. + +Telegram Adapter получает адрес заглушки через `TELEGRAM_API_URL`. Production-адрес не зашивается в +образ и не меняется в коде сервиса. Настоящие bot token, webhook secret и ключ шифрования не хранятся +в репозитории; значения из `.env.example` предназначены только для изолированного локального стенда. + +## Последствия + +- полный Compose запускается без Telegram-аккаунта; +- E2E может проверить исходящие ответы через стандартный журнал WireMock; +- `deploy` остаётся владельцем окружения, а продуктовые проверки остаются в `test-lab`; +- перед production WireMock не переносится в кластер, вместо него настраивается настоящий Telegram API. diff --git a/docs/development.md b/docs/development.md index 137dc04..36b9d4e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -16,7 +16,7 @@ Taskfile — единая точка входа. Внутренние PowerShell 5. Перед pull request выполни `task test:e2e`. Допустимые имена: `channel-gateway`, `conversation-service`, `agent-runtime`, `action-service`, -`mcp-gateway` и `calendar-mcp`. Неизвестное имя отклоняется до вызова Docker. +`mcp-gateway`, `calendar-mcp` и `telegram-adapter`. Неизвестное имя отклоняется до вызова Docker. Например, для локальной проверки HTTP-потока диалога выполни: @@ -27,6 +27,15 @@ task service:up SERVICE=conversation-service Compose соберёт сервис из соседнего репозитория, поднимет PostgreSQL, Keycloak, Agent Runtime и Action Service. Отдельно запускать его зависимости не нужно. +Для работы над Telegram Adapter используй ту же команду: + +```powershell +task service:up SERVICE=telegram-adapter +``` + +Она поднимет PostgreSQL, Keycloak, Channel Gateway и локальный fake Telegram API. Настоящий Telegram +bot token для разработки и CI не требуется. + `task test:e2e` поднимает полный локальный срез и ждёт healthchecks. Затем `deploy` вызывает `task test:e2e` в соседнем репозитории `test-lab`, который владеет black-box сценарием от текста до сохранённого события. Если репозитории лежат не рядом, задай `TEST_LAB_PATH` в локальном `.env`. diff --git a/docs/index.md b/docs/index.md index fdf4115..f9a2f9f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ # Deploy Репозиторий содержит инженерный полигон Portable Agent. Compose поднимает инфраструктуру и локальный -slice `Channel Gateway → Conversation Service → Agent Runtime → Action Service → MCP Gateway → Calendar MCP`. Минимальный +slice `Telegram Adapter → Channel Gateway → Conversation Service → Agent Runtime → Action Service → MCP Gateway → Calendar MCP`. Минимальный Helm chart устанавливается и проверяется в одноразовом k3d-кластере. Текущий результат: @@ -11,7 +11,8 @@ Helm chart устанавливается и проверяется в одно 3. общий Channel Gateway с маршрутом Conversation и публичным контрактом `2.4.0`; 4. воспроизводимый Compose с локальной сборкой всех приложений; 5. отдельная БД и Conversation Service, создающий Action и виджет подтверждения; -6. изолированный ephemeral preview namespace для инфраструктурных pull request. +6. изолированный ephemeral preview namespace для инфраструктурных pull request; +7. Telegram Adapter, Keycloak Device Flow и локальный fake Telegram API без настоящего bot token. Первый переносимый виджет подтверждения входит в системный acceptance-путь. Публичный preview URL появится после подключения общего Kubernetes-кластера и контроллера жизненного цикла окружений. diff --git a/scripts/check-compose.ps1 b/scripts/check-compose.ps1 index bb504cf..9d44208 100644 --- a/scripts/check-compose.ps1 +++ b/scripts/check-compose.ps1 @@ -42,6 +42,19 @@ $actionClient = $realm.clients | Where-Object clientId -eq "action-service" if (-not $actionClient -or -not $actionClient.serviceAccountsEnabled -or $actionClient.publicClient) { throw "Нет confidential service account client action-service." } +$telegramClient = $realm.clients | Where-Object clientId -eq "telegram-adapter" +if (-not $telegramClient -or $telegramClient.publicClient ` + -or $telegramClient.attributes.'oauth2.device.authorization.grant.enabled' -ne "true") { + throw "Нет confidential Device Flow client telegram-adapter." +} +$telegramAudiences = @($telegramClient.protocolMappers | ForEach-Object { $_.config.'included.client.audience' }) +$telegramTenantMapper = $telegramClient.protocolMappers | Where-Object { $_.config.'claim.name' -eq "tenant_id" } +if (-not $telegramTenantMapper -or $telegramAudiences -notcontains "channel-gateway" ` + -or $telegramAudiences -notcontains "conversation-service" ` + -or $telegramAudiences -notcontains "agent-runtime" ` + -or $telegramAudiences -notcontains "action-service") { + throw "Device Flow token не содержит tenant_id и audience пользовательского пути." +} $exampleTenant = (Get-Content -LiteralPath ".env.example" | Where-Object { $_ -match '^ACTION_SERVICE_TENANT_ID=' }).Split('=', 2)[1] if ($exampleTenant -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$') { throw "Локальный tenant должен быть RFC-совместимым UUID." @@ -85,6 +98,27 @@ foreach ($service in @("channel-gateway", "agent-runtime", "action-service", "co throw "В Compose нет приложения $service." } } +foreach ($service in @("telegram-adapter", "fake-telegram")) { + if ($composeText -notmatch "(?m)^ $([regex]::Escape($service)):") { + throw "В Compose нет Telegram-компонента $service." + } +} +if ($composeText -notmatch '(?ms)^ telegram-adapter:.*?TELEGRAM_API_URL: http://fake-telegram:8080' ` + -or $composeText -notmatch '(?ms)^ telegram-adapter:.*?CHANNEL_GATEWAY_URL: http://channel-gateway:8080' ` + -or $composeText -notmatch '(?ms)^ telegram-adapter:.*?DATABASE_URL: postgres://.*@postgres:5432/telegram_adapter' ` + -or $composeText -notmatch '(?ms)^ telegram-adapter:.*?^ healthcheck:') { + throw "Compose не связывает Telegram Adapter с локальными зависимостями." +} +$telegramMappingPath = "compose/telegram/mappings/bot-api.json" +if (-not (Test-Path -LiteralPath $telegramMappingPath)) { + throw "Нет безопасной заглушки Telegram Bot API." +} +$telegramMapping = Get-Content -Raw -LiteralPath $telegramMappingPath | ConvertFrom-Json +if ($telegramMapping.request.method -ne "POST" ` + -or $telegramMapping.request.urlPathPattern -notmatch 'sendMessage' ` + -or $telegramMapping.request.urlPathPattern -notmatch 'answerCallbackQuery') { + throw "Fake Telegram API не поддерживает ответы адаптера." +} if ($composeText -notmatch '(?ms)^ channel-gateway:.*?OIDC_AUDIENCE: channel-gateway' ` -or $composeText -notmatch '(?ms)^ channel-gateway:.*?AGENT_URL: http://agent-runtime:8080' ` -or $composeText -notmatch '(?ms)^ channel-gateway:.*?CONVERSATION_URL: http://conversation-service:8080' ` @@ -127,8 +161,10 @@ if ($postgresBootstrap -notmatch 'exec /bin/sh /scripts/init/01-users\.sh') { $postgresInit = Get-Content -Raw -LiteralPath "compose/postgres/init/01-users.sh" if ($postgresInit -notmatch 'CONVERSATION_DB_USER' ` -or $postgresInit -notmatch 'CONVERSATION_DB_PASSWORD' ` - -or $postgresInit -notmatch 'CREATE DATABASE conversations') { - throw "PostgreSQL bootstrap не создаёт отдельную БД Conversation Service." + -or $postgresInit -notmatch 'CREATE DATABASE conversations' ` + -or $postgresInit -notmatch 'TELEGRAM_DB_USER' ` + -or $postgresInit -notmatch 'CREATE DATABASE telegram_adapter') { + throw "PostgreSQL bootstrap не создаёт отдельные БД приложений." } if ($startScript -notmatch 'scripts/check-keycloak.ps1') { throw "После запуска нужна runtime-проверка Keycloak fixture." @@ -139,6 +175,11 @@ if ($startScript -notmatch '\[switch\]\$Apps' -or $startScript -notmatch '"apps" if ($startScript -notmatch 'compose/apps.local.yaml' -or $startScript -notmatch 'up -d --build --wait') { throw "Локальные приложения должны собираться из соседних репозиториев." } +$localSettings = Get-Content -Raw -LiteralPath "scripts/local-settings.ps1" +if ($localSettings -notmatch 'SHA256' -or $localSettings -notmatch 'TELEGRAM_TOKEN_KEY_BASE64' ` + -or $localSettings -notmatch 'TELEGRAM_WEBHOOK_SECRET') { + throw "Локальный encryption key Telegram Adapter должен создаваться вне Git." +} $taskfilePath = "Taskfile.yml" if (-not (Test-Path -LiteralPath $taskfilePath)) { throw "Нет единой точки входа Taskfile.yml для локальной разработки." @@ -181,7 +222,7 @@ foreach ($action in @("Status", "Stop", "Restart", "Logs")) { } } $appsOverride = Get-Content -Raw -LiteralPath "compose/apps.local.yaml" -foreach ($image in @("portable-agent/channel-gateway:local", "portable-agent/agent-runtime:local", "portable-agent/action-service:local", "portable-agent/conversation-service:local", "portable-agent/mcp-gateway:local", "portable-agent/calendar-mcp:local")) { +foreach ($image in @("portable-agent/channel-gateway:local", "portable-agent/agent-runtime:local", "portable-agent/action-service:local", "portable-agent/conversation-service:local", "portable-agent/mcp-gateway:local", "portable-agent/calendar-mcp:local", "portable-agent/telegram-adapter:local")) { if ($appsOverride -notmatch [regex]::Escape($image)) { throw "Local override не задаёт отдельный image tag $image." } @@ -219,6 +260,9 @@ if ($versions -notmatch '(?m)^AGENT_RUNTIME_IMAGE=ghcr\.io/portable-agent/agent- if ($versions -notmatch '(?m)^CHANNEL_GATEWAY_IMAGE=ghcr\.io/portable-agent/channel-gateway:[0-9a-f]{40}\r?$') { throw "Channel Gateway image должен быть закреплён полным Git SHA." } +if ($versions -notmatch '(?m)^TELEGRAM_ADAPTER_IMAGE=ghcr\.io/portable-agent/telegram-adapter:[0-9a-f]{40}\r?$') { + throw "Telegram Adapter image должен быть закреплён полным Git SHA." +} if ($versions -notmatch '(?m)^TEST_LAB_REF=[0-9a-f]{40}\r?$') { throw "Test Lab должен быть закреплён полным Git SHA." } @@ -227,7 +271,7 @@ if (-not (Test-Path -LiteralPath $appWorkflowPath)) { throw "Нет CI-проверки полного Compose-среза." } $appWorkflow = Get-Content -Raw -LiteralPath $appWorkflowPath -foreach ($required in @("versions.env", "channel-gateway", "agent-runtime", "CONVERSATION_SERVICE_CONTEXT", "repository: portable-agent/conversation-service", 'ref: ${{ steps.versions.outputs.conversation }}', "repository: portable-agent/test-lab", 'ref: ${{ steps.versions.outputs.test_lab }}', "start-local.ps1 -Apps", "run-test-lab.ps1", "stop-local.ps1 -DeleteData", "if: always()")) { +foreach ($required in @("versions.env", "channel-gateway", "agent-runtime", "CONVERSATION_SERVICE_CONTEXT", "repository: portable-agent/conversation-service", 'ref: ${{ steps.versions.outputs.conversation }}', "TELEGRAM_ADAPTER_CONTEXT", "repository: portable-agent/telegram-adapter", 'ref: ${{ steps.versions.outputs.telegram }}', "repository: portable-agent/test-lab", 'ref: ${{ steps.versions.outputs.test_lab }}', "start-local.ps1 -Apps", "run-test-lab.ps1", "stop-local.ps1 -DeleteData", "if: always()")) { if ($appWorkflow -notmatch [regex]::Escape($required)) { throw "CI-проверка полного среза не содержит $required." } diff --git a/scripts/check-keycloak.ps1 b/scripts/check-keycloak.ps1 index b86f79f..b1cbce6 100644 --- a/scripts/check-keycloak.ps1 +++ b/scripts/check-keycloak.ps1 @@ -1,6 +1,7 @@ param( [Parameter(Mandatory = $true)][string]$BaseUrl, [Parameter(Mandatory = $true)][string]$ServiceSecret, + [Parameter(Mandatory = $true)][string]$TelegramSecret, [Parameter(Mandatory = $true)][string]$ExpectedTenant ) $ErrorActionPreference = "Stop" @@ -10,6 +11,7 @@ $client = $realm.clients | Where-Object clientId -eq "portable-agent-local" $user = $realm.users | Where-Object username -eq "local-user" $password = ($user.credentials | Where-Object type -eq "password").value $serviceClient = $realm.clients | Where-Object clientId -eq "action-service" +$telegramClient = $realm.clients | Where-Object clientId -eq "telegram-adapter" try { $tokenResponse = Invoke-RestMethod -Method Post ` @@ -53,6 +55,17 @@ try { -or $serviceScopes -notcontains "calendar:write") { throw "Service token action-service не содержит нужные claims." } + + $device = Invoke-RestMethod -Method Post ` + -Uri "$BaseUrl/realms/$($realm.realm)/protocol/openid-connect/auth/device" ` + -Body @{ + client_id = $telegramClient.clientId + client_secret = $TelegramSecret + scope = "openid offline_access" + } + if (-not $device.device_code -or -not $device.user_code -or -not $device.verification_uri) { + throw "Device Flow telegram-adapter не вернул обязательные поля." + } } catch { throw "Локальный Keycloak не соответствует compose/keycloak/portable-agent-realm.json. Для обновления тестовых данных выполни './scripts/stop-local.ps1 -DeleteData', затем запусти стенд снова. Причина: $($_.Exception.Message)" } diff --git a/scripts/local-settings.ps1 b/scripts/local-settings.ps1 new file mode 100644 index 0000000..0c30b94 --- /dev/null +++ b/scripts/local-settings.ps1 @@ -0,0 +1,24 @@ +function Get-LocalSetting([string]$Name) { + $value = [Environment]::GetEnvironmentVariable($Name) + foreach ($path in @(".env", ".env.example")) { + if ($value -or -not (Test-Path -LiteralPath $path)) { continue } + $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." } + return $value +} + +function Initialize-LocalTelegramKey { + $key = Get-LocalSetting "TELEGRAM_TOKEN_KEY_BASE64" + if ($key -ne "derive-from-local-webhook-secret") { return } + + $webhookSecret = Get-LocalSetting "TELEGRAM_WEBHOOK_SECRET" + $hash = [Security.Cryptography.SHA256]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes("portable-agent-local:$webhookSecret") + $env:TELEGRAM_TOKEN_KEY_BASE64 = [Convert]::ToBase64String($hash.ComputeHash($bytes)) + } finally { + $hash.Dispose() + } +} diff --git a/scripts/service-local.ps1 b/scripts/service-local.ps1 index eeab46a..cec47d7 100644 --- a/scripts/service-local.ps1 +++ b/scripts/service-local.ps1 @@ -2,13 +2,15 @@ [Parameter(Mandatory = $true)] [ValidateSet("Status", "Stop", "Restart", "Logs")] [string]$Action, - [ValidateSet("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp")] + [ValidateSet("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp", "telegram-adapter")] [string]$Service, [ValidateRange(1, 10000)] [int]$Tail = 100 ) $ErrorActionPreference = "Stop" +. "$PSScriptRoot/local-settings.ps1" +Initialize-LocalTelegramKey if ($Action -ne "Status" -and -not $Service) { throw "Для команды $Action укажи -Service." } diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 index 7df6ec4..5596d06 100644 --- a/scripts/start-local.ps1 +++ b/scripts/start-local.ps1 @@ -1,26 +1,18 @@ param( [switch]$Observe, [switch]$Apps, - [ValidateSet("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp")] + [ValidateSet("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp", "telegram-adapter")] [string]$Service ) $ErrorActionPreference = "Stop" if ($Apps -and $Service) { throw "Используй -Apps или -Service, но не оба параметра одновременно." } +. "$PSScriptRoot/local-settings.ps1" $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") } -function Get-LocalSetting([string]$Name) { - $value = [Environment]::GetEnvironmentVariable($Name) - foreach ($path in @(".env", ".env.example")) { - if ($value -or -not (Test-Path -LiteralPath $path)) { continue } - $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." } - return $value -} +Initialize-LocalTelegramKey $coreProfiles = @("--profile", "core") if ($Observe) { $coreProfiles += @("--profile", "observe") } & docker compose @envFiles @composeFiles @coreProfiles run --rm postgres-bootstrap @@ -33,6 +25,7 @@ $keycloakAddress = & docker compose @envFiles @composeFiles port keycloak 8080 if ($LASTEXITCODE -ne 0 -or -not $keycloakAddress) { throw "Не удалось определить адрес Keycloak." } $keycloakPort = $keycloakAddress.Trim().Split(':')[-1] $serviceSecret = Get-LocalSetting "ACTION_SERVICE_CLIENT_SECRET" +$telegramSecret = Get-LocalSetting "TELEGRAM_ADAPTER_CLIENT_SECRET" $tenantId = Get-LocalSetting "ACTION_SERVICE_TENANT_ID" if ($tenantId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$') { throw "ACTION_SERVICE_TENANT_ID должен быть RFC-совместимым UUID." @@ -40,6 +33,7 @@ if ($tenantId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89a & ./scripts/check-keycloak.ps1 ` -BaseUrl "http://localhost:$keycloakPort" ` -ServiceSecret $serviceSecret ` + -TelegramSecret $telegramSecret ` -ExpectedTenant $tenantId if ($LASTEXITCODE -ne 0) { throw "Локальный Keycloak не прошёл runtime-проверку." } if ($Apps) {