diff --git a/.env.example b/.env.example index 6eceec2..e4aab26 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,8 @@ SPRING_DATASOURCE_PASSWORD=atlas ATLAS_TELEGRAM_ENABLED=false ATLAS_TELEGRAM_BOT_TOKEN= ATLAS_TELEGRAM_BOT_USERNAME= +ATLAS_TELEGRAM_WEBHOOK_PATH=/telegram/webhook +ATLAS_TELEGRAM_WEBHOOK_SECRET= +ATLAS_PUBLIC_BASE_URL= +ATLAS_TELEGRAM_REGISTER_WEBHOOK_ON_STARTUP=false +ATLAS_TELEGRAM_DROP_PENDING_UPDATES_ON_WEBHOOK_REGISTRATION=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad3774..d88ad71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.3.3 + +- Added production Telegram webhook configuration +- Added Telegram webhook secret validation +- Added optional webhook registration on application startup +- Added production-safe Telegram update logging +- Added Telegram production deployment documentation +- Expanded Telegram production test coverage + ## v0.3.2 - Removed obsolete frontend-related artifacts from the backend repository diff --git a/README.md b/README.md index 620645c..c6f9e24 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,34 @@ ATLAS_TELEGRAM_BOT_USERNAME= Не добавляй реальные секреты в репозиторий. +

Production Telegram Launch

+ +Production-запуск Telegram-бота описан в [deployment guide](docs/deployment/telegram-production.md). + +Минимальные production-переменные: + +```bash +ATLAS_TELEGRAM_ENABLED=true +ATLAS_TELEGRAM_BOT_TOKEN= +ATLAS_TELEGRAM_BOT_USERNAME= +ATLAS_TELEGRAM_WEBHOOK_PATH=/telegram/webhook +ATLAS_TELEGRAM_WEBHOOK_SECRET= +ATLAS_PUBLIC_BASE_URL=https:// +ATLAS_TELEGRAM_REGISTER_WEBHOOK_ON_STARTUP=true +``` + +Production health endpoint: + +```text +GET /actuator/health +``` + +Telegram webhook endpoint: + +```text +POST /telegram/webhook +``` +

Roadmap

Основная продуктовая дорожная карта: diff --git a/docker-compose.yml b/docker-compose.yml index cbb6aa5..dd8304c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,11 @@ services: ATLAS_TELEGRAM_ENABLED: ${ATLAS_TELEGRAM_ENABLED:-false} ATLAS_TELEGRAM_BOT_TOKEN: ${ATLAS_TELEGRAM_BOT_TOKEN:-} ATLAS_TELEGRAM_BOT_USERNAME: ${ATLAS_TELEGRAM_BOT_USERNAME:-} + ATLAS_TELEGRAM_WEBHOOK_PATH: ${ATLAS_TELEGRAM_WEBHOOK_PATH:-/telegram/webhook} + ATLAS_TELEGRAM_WEBHOOK_SECRET: ${ATLAS_TELEGRAM_WEBHOOK_SECRET:-} + ATLAS_PUBLIC_BASE_URL: ${ATLAS_PUBLIC_BASE_URL:-} + ATLAS_TELEGRAM_REGISTER_WEBHOOK_ON_STARTUP: ${ATLAS_TELEGRAM_REGISTER_WEBHOOK_ON_STARTUP:-false} + ATLAS_TELEGRAM_DROP_PENDING_UPDATES_ON_WEBHOOK_REGISTRATION: ${ATLAS_TELEGRAM_DROP_PENDING_UPDATES_ON_WEBHOOK_REGISTRATION:-true} ports: - "${ATLAS_APP_PORT:-8080}:8080" healthcheck: diff --git a/docs/deployment/telegram-production.md b/docs/deployment/telegram-production.md new file mode 100644 index 0000000..db5b3f7 --- /dev/null +++ b/docs/deployment/telegram-production.md @@ -0,0 +1,158 @@ +# Telegram Production Deployment + +Этот чеклист описывает production-запуск ATLAS backend для Telegram-бота. + +## BotFather Checklist + +- Создай бота через BotFather. +- Сохрани bot token вне репозитория. +- Задай bot username и description. +- Отключи privacy mode только если это нужно для групповых чатов. +- Не публикуй token в README, логах, скриншотах или issue. + +## Required Environment Variables + +```bash +ATLAS_TELEGRAM_ENABLED=true +ATLAS_TELEGRAM_BOT_TOKEN= +ATLAS_TELEGRAM_BOT_USERNAME= +ATLAS_TELEGRAM_WEBHOOK_PATH=/telegram/webhook +ATLAS_TELEGRAM_WEBHOOK_SECRET= +ATLAS_PUBLIC_BASE_URL=https:// +ATLAS_TELEGRAM_REGISTER_WEBHOOK_ON_STARTUP=false +ATLAS_TELEGRAM_DROP_PENDING_UPDATES_ON_WEBHOOK_REGISTRATION=true +``` + +## Example `.env.production` + +```bash +POSTGRES_DB=atlas +POSTGRES_USER=atlas +POSTGRES_PASSWORD= + +SPRING_DATASOURCE_URL=jdbc:postgresql://atlas-postgres:5432/atlas +SPRING_DATASOURCE_USERNAME=atlas +SPRING_DATASOURCE_PASSWORD= + +ATLAS_TELEGRAM_ENABLED=true +ATLAS_TELEGRAM_BOT_TOKEN= +ATLAS_TELEGRAM_BOT_USERNAME= +ATLAS_TELEGRAM_WEBHOOK_PATH=/telegram/webhook +ATLAS_TELEGRAM_WEBHOOK_SECRET= +ATLAS_PUBLIC_BASE_URL=https:// +ATLAS_TELEGRAM_REGISTER_WEBHOOK_ON_STARTUP=true +ATLAS_TELEGRAM_DROP_PENDING_UPDATES_ON_WEBHOOK_REGISTRATION=true +``` + +## HTTPS Requirement + +Telegram must reach the webhook through a public HTTPS URL. `ATLAS_PUBLIC_BASE_URL` must start with `https://` when automatic webhook registration is enabled. + +## Docker Deployment Checklist + +- Build and publish the backend image. +- Provision PostgreSQL and set datasource environment variables. +- Set all Telegram variables in the deployment environment. +- Keep `ATLAS_TELEGRAM_ENABLED=false` for local development unless testing a real bot. +- Expose the application on port `8080` behind HTTPS. +- Verify health before registering the webhook. + +Healthcheck: + +```bash +curl -f https:///actuator/health +``` + +Local container health endpoint: + +```bash +curl -f http://localhost:8080/actuator/health +``` + +## Webhook Registration + +### Option 1: Automatic Registration On Startup + +Set: + +```bash +ATLAS_TELEGRAM_REGISTER_WEBHOOK_ON_STARTUP=true +ATLAS_PUBLIC_BASE_URL=https:// +ATLAS_TELEGRAM_WEBHOOK_PATH=/telegram/webhook +``` + +On startup the backend registers: + +```text +https:///telegram/webhook +``` + +Automatic registration fails fast if the public URL is missing, is not HTTPS, or the webhook path does not start with `/`. + +### Option 2: Manual `setWebhook` + +Keep automatic registration disabled: + +```bash +ATLAS_TELEGRAM_REGISTER_WEBHOOK_ON_STARTUP=false +``` + +Register manually: + +```bash +curl -X POST "https://api.telegram.org/bot${ATLAS_TELEGRAM_BOT_TOKEN}/setWebhook" \ + -d "url=${ATLAS_PUBLIC_BASE_URL}${ATLAS_TELEGRAM_WEBHOOK_PATH}" \ + -d "secret_token=${ATLAS_TELEGRAM_WEBHOOK_SECRET}" \ + -d "drop_pending_updates=${ATLAS_TELEGRAM_DROP_PENDING_UPDATES_ON_WEBHOOK_REGISTRATION}" \ + -d 'allowed_updates=["message"]' +``` + +Check webhook status: + +```bash +curl "https://api.telegram.org/bot${ATLAS_TELEGRAM_BOT_TOKEN}/getWebhookInfo" +``` + +## Smoke Test + +- `GET /actuator/health` returns `UP`. +- Application starts with `ATLAS_TELEGRAM_ENABLED=true` and a configured bot token. +- Startup logs show Telegram integration enabled. +- If automatic registration is enabled, startup logs show webhook registration success. +- Send `/start` to the bot in Telegram. +- Bot replies in Telegram. +- Send `/day` and verify a planner response. +- Send an unsupported or blank update through the webhook and verify the backend does not crash. +- Verify logs contain `update_id`, `chat_id`, `handled`, and `request_type` where available. +- Verify logs do not contain bot token, webhook secret, or full user message text. + +## Common Issues + +Bot does not answer: +Check `ATLAS_TELEGRAM_ENABLED`, bot token, webhook URL, app logs, and `getWebhookInfo`. + +Health is down: +Check datasource variables, PostgreSQL availability, migrations, container logs, and `/actuator/health`. + +Wrong webhook URL: +Verify `ATLAS_PUBLIC_BASE_URL` and `ATLAS_TELEGRAM_WEBHOOK_PATH`. The final URL must be reachable by Telegram over HTTPS. + +Invalid token: +Regenerate the token in BotFather and update only the deployment secret store. + +Webhook secret mismatch: +The value in `ATLAS_TELEGRAM_WEBHOOK_SECRET` must match the `secret_token` used in `setWebhook`. + +App is running but Telegram cannot reach it: +Check DNS, HTTPS certificate, reverse proxy routing, firewall rules, and public access to the webhook path. + +Pending updates after deploy: +Use `ATLAS_TELEGRAM_DROP_PENDING_UPDATES_ON_WEBHOOK_REGISTRATION=true` during registration when old updates should be discarded. + +## Security Notes + +- Never commit the bot token. +- Use `ATLAS_TELEGRAM_WEBHOOK_SECRET` in production. +- Use HTTPS for the public webhook URL. +- Keep Telegram disabled locally by default. +- Store production secrets in the deployment secret manager or environment, not in git. diff --git a/pom.xml b/pom.xml index 401b801..75b0796 100644 --- a/pom.xml +++ b/pom.xml @@ -13,9 +13,9 @@ com.example atlas - 0.3.2 + 0.3.3 ATLAS - Personal AI system for rhythm, training, recovery, habits, nutrition and progress. + Personal system for rhythm, training, recovery, habits, nutrition and progress. 21 diff --git a/src/main/java/com/example/atlas/config/AtlasProperties.java b/src/main/java/com/example/atlas/config/AtlasProperties.java index 4aa0a2e..9398e2f 100644 --- a/src/main/java/com/example/atlas/config/AtlasProperties.java +++ b/src/main/java/com/example/atlas/config/AtlasProperties.java @@ -7,17 +7,46 @@ public record AtlasProperties(Telegram telegram) { public AtlasProperties { if (telegram == null) { - telegram = new Telegram(false, "", ""); + telegram = new Telegram(false, "", "", "/telegram/webhook", "", "", false, true); } } public record Telegram( boolean enabled, String botToken, - String botUsername + String botUsername, + String webhookPath, + String webhookSecret, + String publicBaseUrl, + boolean registerWebhookOnStartup, + boolean dropPendingUpdatesOnWebhookRegistration ) { + public Telegram { + botToken = defaultString(botToken); + botUsername = defaultString(botUsername); + webhookPath = defaultString(webhookPath, "/telegram/webhook"); + webhookSecret = defaultString(webhookSecret); + publicBaseUrl = defaultString(publicBaseUrl); + } + public boolean hasBotToken() { return botToken != null && !botToken.isBlank(); } + + public boolean hasWebhookSecret() { + return webhookSecret != null && !webhookSecret.isBlank(); + } + + public boolean hasPublicBaseUrl() { + return publicBaseUrl != null && !publicBaseUrl.isBlank(); + } + + private static String defaultString(String value) { + return defaultString(value, ""); + } + + private static String defaultString(String value, String fallback) { + return value == null ? fallback : value; + } } } diff --git a/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java b/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java index 4a5eaec..4796057 100644 --- a/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java +++ b/src/main/java/com/example/atlas/orchestrator/OrchestratorService.java @@ -22,6 +22,10 @@ public OrchestratorService(List agents) { public AgentResult route(String message) { RequestType requestType = resolveRequestType(message); + return route(requestType, message); + } + + public AgentResult route(RequestType requestType, String message) { AgentContext context = AgentContext.anonymous(message, requestType); List results = agents.stream() diff --git a/src/main/java/com/example/atlas/telegram/TelegramMessageSender.java b/src/main/java/com/example/atlas/telegram/TelegramMessageSender.java index ce39776..d29330d 100644 --- a/src/main/java/com/example/atlas/telegram/TelegramMessageSender.java +++ b/src/main/java/com/example/atlas/telegram/TelegramMessageSender.java @@ -23,13 +23,23 @@ public TelegramMessageSender(TelegramApiClient telegramApiClient) { } public void sendText(long chatId, String text) { - for (String chunk : splitText(text)) { + List chunks = splitText(text); + for (int index = 0; index < chunks.size(); index++) { + String chunk = chunks.get(index); try { telegramApiClient.sendMessage(chatId, chunk); + log.info( + "Telegram sendMessage succeeded: chat_id={}, chunk_index={}, chunk_count={}", + chatId, + index + 1, + chunks.size() + ); } catch (RuntimeException exception) { log.warn( - "Telegram sendMessage failed for chat {} with {}", + "Telegram sendMessage failed: chat_id={}, chunk_index={}, chunk_count={}, error_type={}", chatId, + index + 1, + chunks.size(), exception.getClass().getSimpleName() ); } diff --git a/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java b/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java index 80c6c0e..706defb 100644 --- a/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java +++ b/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java @@ -1,6 +1,7 @@ package com.example.atlas.telegram; import com.example.atlas.orchestrator.OrchestratorService; +import com.example.atlas.orchestrator.RequestType; import com.example.atlas.safety.SafetyGuard; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,31 +30,65 @@ public TelegramUpdateHandler( public boolean handleUpdate(TelegramUpdate update) { if (update == null || update.message() == null) { - log.debug("Ignoring unsupported Telegram update without a text message"); + log.info( + "Telegram update handled: update_id={}, chat_id={}, handled={}, reason={}", + updateId(update), + null, + false, + "unsupported_update" + ); return false; } TelegramUpdate.TelegramMessage message = update.message(); if (message.chat() == null || message.chat().id() == null) { - log.debug("Ignoring Telegram message without chat id"); + log.info( + "Telegram update handled: update_id={}, chat_id={}, handled={}, reason={}", + updateId(update), + null, + false, + "missing_chat_id" + ); return false; } if (message.text() == null || message.text().isBlank()) { - log.debug("Ignoring Telegram non-text or blank message"); + log.info( + "Telegram update handled: update_id={}, chat_id={}, handled={}, reason={}", + updateId(update), + message.chat().id(), + false, + "blank_text" + ); return false; } - String response = handleTextMessage(message.text()); + RequestType requestType = orchestratorService.resolveRequestType(message.text()); + String response = handleTextMessage(message.text(), requestType); messageSender.sendText(message.chat().id(), response); + log.info( + "Telegram update handled: update_id={}, chat_id={}, handled={}, request_type={}", + updateId(update), + message.chat().id(), + true, + requestType + ); return true; } public String handleTextMessage(String text) { + return handleTextMessage(text, orchestratorService.resolveRequestType(text)); + } + + private String handleTextMessage(String text, RequestType requestType) { if (safetyGuard.requiresSafetyResponse(text)) { return safetyGuard.safetyResponse(); } - return orchestratorService.route(text).content(); + return orchestratorService.route(requestType, text).content(); + } + + private Long updateId(TelegramUpdate update) { + return update == null ? null : update.updateId(); } } diff --git a/src/main/java/com/example/atlas/telegram/TelegramWebhookClient.java b/src/main/java/com/example/atlas/telegram/TelegramWebhookClient.java new file mode 100644 index 0000000..a036776 --- /dev/null +++ b/src/main/java/com/example/atlas/telegram/TelegramWebhookClient.java @@ -0,0 +1,130 @@ +package com.example.atlas.telegram; + +import com.example.atlas.config.AtlasProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.util.List; + +@Component +@ConditionalOnProperty(prefix = "atlas.telegram", name = "enabled", havingValue = "true") +public class TelegramWebhookClient { + + private static final Logger log = LoggerFactory.getLogger(TelegramWebhookClient.class); + + private final RestClient restClient; + + @Autowired + public TelegramWebhookClient(AtlasProperties properties, RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder + .baseUrl("https://api.telegram.org/bot" + properties.telegram().botToken()) + .build(); + } + + TelegramWebhookClient(RestClient restClient) { + this.restClient = restClient; + } + + public void setWebhook(TelegramWebhookRequest request) { + TelegramWebhookApiResponse response; + try { + response = restClient.post() + .uri("/setWebhook") + .body(TelegramWebhookPayload.from(request)) + .retrieve() + .body(TelegramWebhookApiResponse.class); + } catch (RestClientException exception) { + log.warn("Telegram setWebhook request failed with {}", exception.getClass().getSimpleName()); + throw new IllegalStateException("Telegram webhook registration request failed.", exception); + } + + if (response == null || !response.ok()) { + log.warn("Telegram setWebhook was rejected by Telegram API"); + throw new IllegalStateException("Telegram webhook registration was rejected by Telegram API."); + } + + log.info("Telegram setWebhook accepted by Telegram API"); + } + + public record TelegramWebhookRequest( + String url, + String secretToken, + boolean dropPendingUpdates + ) { + boolean hasSecretToken() { + return secretToken != null && !secretToken.isBlank(); + } + } + + private record TelegramWebhookApiResponse( + boolean ok, + @JsonProperty("description") String description + ) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + private static final class TelegramWebhookPayload { + + private final String url; + private final String secretToken; + private final boolean dropPendingUpdates; + private final List allowedUpdates; + + private TelegramWebhookPayload( + String url, + String secretToken, + boolean dropPendingUpdates, + List allowedUpdates + ) { + this.url = url; + this.secretToken = secretToken; + this.dropPendingUpdates = dropPendingUpdates; + this.allowedUpdates = allowedUpdates; + } + + static TelegramWebhookPayload from(TelegramWebhookRequest request) { + return new TelegramWebhookPayload( + request.url(), + request.hasSecretToken() ? request.secretToken() : null, + request.dropPendingUpdates(), + List.of("message") + ); + } + + public String getUrl() { + return url; + } + + @JsonProperty("secret_token") + public String getSecretToken() { + return secretToken; + } + + @JsonProperty("drop_pending_updates") + public boolean isDropPendingUpdates() { + return dropPendingUpdates; + } + + @JsonProperty("allowed_updates") + public List getAllowedUpdates() { + return allowedUpdates; + } + + @Override + public String toString() { + return "TelegramWebhookPayload{" + + "url='" + url + '\'' + + ", secret_token=" + (secretToken == null ? "absent" : "configured") + + ", drop_pending_updates=" + dropPendingUpdates + + ", allowed_updates=" + allowedUpdates + + '}'; + } + } +} diff --git a/src/main/java/com/example/atlas/telegram/TelegramWebhookController.java b/src/main/java/com/example/atlas/telegram/TelegramWebhookController.java index ba8e24d..f27b83c 100644 --- a/src/main/java/com/example/atlas/telegram/TelegramWebhookController.java +++ b/src/main/java/com/example/atlas/telegram/TelegramWebhookController.java @@ -1,26 +1,54 @@ package com.example.atlas.telegram; +import com.example.atlas.config.AtlasProperties; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + @RestController -@RequestMapping("/telegram") @ConditionalOnProperty(prefix = "atlas.telegram", name = "enabled", havingValue = "true") public class TelegramWebhookController { + static final String SECRET_TOKEN_HEADER = "X-Telegram-Bot-Api-Secret-Token"; + + private final AtlasProperties properties; private final TelegramBotAdapter botAdapter; - public TelegramWebhookController(TelegramBotAdapter botAdapter) { + public TelegramWebhookController(AtlasProperties properties, TelegramBotAdapter botAdapter) { + this.properties = properties; this.botAdapter = botAdapter; } - @PostMapping("/webhook") - public ResponseEntity receiveUpdate(@RequestBody(required = false) TelegramUpdate update) { + @PostMapping("${atlas.telegram.webhook-path:/telegram/webhook}") + public ResponseEntity receiveUpdate( + @RequestHeader(name = SECRET_TOKEN_HEADER, required = false) String secretToken, + @RequestBody(required = false) TelegramUpdate update + ) { + if (!hasValidSecretToken(secretToken)) { + return ResponseEntity.status(403).build(); + } + botAdapter.handleUpdate(update); return ResponseEntity.ok().build(); } + + private boolean hasValidSecretToken(String secretToken) { + if (!properties.telegram().hasWebhookSecret()) { + return true; + } + + if (secretToken == null || secretToken.isBlank()) { + return false; + } + + byte[] expected = properties.telegram().webhookSecret().getBytes(StandardCharsets.UTF_8); + byte[] actual = secretToken.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(expected, actual); + } } diff --git a/src/main/java/com/example/atlas/telegram/TelegramWebhookRegistrationService.java b/src/main/java/com/example/atlas/telegram/TelegramWebhookRegistrationService.java new file mode 100644 index 0000000..056f2c1 --- /dev/null +++ b/src/main/java/com/example/atlas/telegram/TelegramWebhookRegistrationService.java @@ -0,0 +1,93 @@ +package com.example.atlas.telegram; + +import com.example.atlas.config.AtlasProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "atlas.telegram", name = "enabled", havingValue = "true") +public class TelegramWebhookRegistrationService implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(TelegramWebhookRegistrationService.class); + + private final AtlasProperties properties; + private final TelegramWebhookClient webhookClient; + + public TelegramWebhookRegistrationService( + AtlasProperties properties, + TelegramWebhookClient webhookClient + ) { + this.properties = properties; + this.webhookClient = webhookClient; + } + + @Override + public void run(ApplicationArguments args) { + registerWebhookIfEnabled(); + } + + void registerWebhookIfEnabled() { + AtlasProperties.Telegram telegram = properties.telegram(); + if (!telegram.registerWebhookOnStartup()) { + log.info("Telegram webhook registration on startup is disabled"); + return; + } + + validateConfiguration(telegram); + + String webhookUrl = buildWebhookUrl(telegram.publicBaseUrl(), telegram.webhookPath()); + webhookClient.setWebhook(new TelegramWebhookClient.TelegramWebhookRequest( + webhookUrl, + telegram.webhookSecret(), + telegram.dropPendingUpdatesOnWebhookRegistration() + )); + + log.info( + "Telegram webhook registered: url='{}', dropPendingUpdates={}", + webhookUrl, + telegram.dropPendingUpdatesOnWebhookRegistration() + ); + } + + private void validateConfiguration(AtlasProperties.Telegram telegram) { + if (!telegram.hasBotToken()) { + throw new IllegalStateException( + "Telegram webhook registration is enabled, but atlas.telegram.bot-token is missing. " + + "Set ATLAS_TELEGRAM_BOT_TOKEN." + ); + } + + if (!telegram.hasPublicBaseUrl()) { + throw new IllegalStateException( + "Telegram webhook registration is enabled, but atlas.telegram.public-base-url is missing. " + + "Set ATLAS_PUBLIC_BASE_URL." + ); + } + + if (!telegram.publicBaseUrl().startsWith("https://")) { + throw new IllegalStateException( + "Telegram webhook registration requires atlas.telegram.public-base-url to start with https://." + ); + } + + if (telegram.webhookPath() == null || telegram.webhookPath().isBlank()) { + throw new IllegalStateException( + "Telegram webhook registration requires atlas.telegram.webhook-path to be configured." + ); + } + + if (!telegram.webhookPath().startsWith("/")) { + throw new IllegalStateException( + "Telegram webhook registration requires atlas.telegram.webhook-path to start with /." + ); + } + } + + private String buildWebhookUrl(String publicBaseUrl, String webhookPath) { + return publicBaseUrl.replaceAll("/+$", "") + webhookPath; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5757e7d..5e91629 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,3 +27,8 @@ atlas: enabled: ${ATLAS_TELEGRAM_ENABLED:false} bot-token: ${ATLAS_TELEGRAM_BOT_TOKEN:} bot-username: ${ATLAS_TELEGRAM_BOT_USERNAME:} + webhook-path: ${ATLAS_TELEGRAM_WEBHOOK_PATH:/telegram/webhook} + webhook-secret: ${ATLAS_TELEGRAM_WEBHOOK_SECRET:} + public-base-url: ${ATLAS_PUBLIC_BASE_URL:} + register-webhook-on-startup: ${ATLAS_TELEGRAM_REGISTER_WEBHOOK_ON_STARTUP:false} + drop-pending-updates-on-webhook-registration: ${ATLAS_TELEGRAM_DROP_PENDING_UPDATES_ON_WEBHOOK_REGISTRATION:true} diff --git a/src/main/resources/prompts/atlas-system-prompt.md b/src/main/resources/prompts/atlas-system-prompt.md index 93ff878..3a4502c 100644 --- a/src/main/resources/prompts/atlas-system-prompt.md +++ b/src/main/resources/prompts/atlas-system-prompt.md @@ -1,6 +1,6 @@ # ATLAS System Prompt -Ты ATLAS — спокойная персональная AI-система для режима, спорта, восстановления, привычек, питания и прогресса. +Ты ATLAS — спокойная персональная система для режима, спорта, восстановления, привычек, питания и прогресса. Главный принцип: реалистичный план лучше идеального плана, который не будет выполнен. diff --git a/src/test/java/com/example/atlas/telegram/TelegramWebhookClientTest.java b/src/test/java/com/example/atlas/telegram/TelegramWebhookClientTest.java new file mode 100644 index 0000000..c561570 --- /dev/null +++ b/src/test/java/com/example/atlas/telegram/TelegramWebhookClientTest.java @@ -0,0 +1,87 @@ +package com.example.atlas.telegram; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class TelegramWebhookClientTest { + + @Test + void setWebhookIncludesAllowedUpdatesAndSecretTokenWhenConfigured() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + TelegramWebhookClient client = new TelegramWebhookClient( + builder.baseUrl("https://api.telegram.org/bottest-token").build() + ); + server.expect(once(), requestTo("https://api.telegram.org/bottest-token/setWebhook")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.url").value("https://atlas.example/telegram/webhook")) + .andExpect(jsonPath("$.secret_token").value("webhook-secret")) + .andExpect(jsonPath("$.drop_pending_updates").value(true)) + .andExpect(jsonPath("$.allowed_updates[0]").value("message")) + .andRespond(withSuccess("{\"ok\":true}", MediaType.APPLICATION_JSON)); + + client.setWebhook(new TelegramWebhookClient.TelegramWebhookRequest( + "https://atlas.example/telegram/webhook", + "webhook-secret", + true + )); + + server.verify(); + } + + @Test + void setWebhookOmitsSecretTokenWhenBlank() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + TelegramWebhookClient client = new TelegramWebhookClient( + builder.baseUrl("https://api.telegram.org/bottest-token").build() + ); + server.expect(once(), requestTo("https://api.telegram.org/bottest-token/setWebhook")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.secret_token").doesNotExist()) + .andExpect(jsonPath("$.allowed_updates[0]").value("message")) + .andRespond(withSuccess("{\"ok\":true}", MediaType.APPLICATION_JSON)); + + client.setWebhook(new TelegramWebhookClient.TelegramWebhookRequest( + "https://atlas.example/telegram/webhook", + "", + true + )); + + server.verify(); + } + + @Test + void setWebhookRejectsFailedTelegramApiResponse() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + TelegramWebhookClient client = new TelegramWebhookClient( + builder.baseUrl("https://api.telegram.org/bottest-token").build() + ); + server.expect(once(), requestTo("https://api.telegram.org/bottest-token/setWebhook")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess("{\"ok\":false,\"description\":\"bad webhook\"}", MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.setWebhook(new TelegramWebhookClient.TelegramWebhookRequest( + "https://atlas.example/telegram/webhook", + "", + true + ))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("rejected"); + + server.verify(); + } +} diff --git a/src/test/java/com/example/atlas/telegram/TelegramWebhookControllerTest.java b/src/test/java/com/example/atlas/telegram/TelegramWebhookControllerTest.java new file mode 100644 index 0000000..c5ad108 --- /dev/null +++ b/src/test/java/com/example/atlas/telegram/TelegramWebhookControllerTest.java @@ -0,0 +1,132 @@ +package com.example.atlas.telegram; + +import com.example.atlas.config.AtlasProperties; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +class TelegramWebhookControllerTest { + + private final RecordingTelegramBotAdapter botAdapter = new RecordingTelegramBotAdapter(); + + @Test + void configuredSecretWithValidHeaderIsAccepted() { + TelegramWebhookController controller = controllerWithSecret("expected-secret"); + TelegramUpdate update = textUpdate("/start"); + + ResponseEntity response = controller.receiveUpdate("expected-secret", update); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(botAdapter.handledUpdate()).isSameAs(update); + } + + @Test + void configuredSecretWithMissingHeaderIsRejected() { + TelegramWebhookController controller = controllerWithSecret("expected-secret"); + TelegramUpdate update = textUpdate("/start"); + + ResponseEntity response = controller.receiveUpdate(null, update); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(botAdapter.handledUpdate()).isNull(); + } + + @Test + void configuredSecretWithInvalidHeaderIsRejected() { + TelegramWebhookController controller = controllerWithSecret("expected-secret"); + TelegramUpdate update = textUpdate("/start"); + + ResponseEntity response = controller.receiveUpdate("wrong-secret", update); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(botAdapter.handledUpdate()).isNull(); + } + + @Test + void blankConfiguredSecretKeepsBackwardCompatibleRequestHandling() { + TelegramWebhookController controller = controllerWithSecret(""); + TelegramUpdate update = textUpdate("/start"); + + ResponseEntity response = controller.receiveUpdate(null, update); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(botAdapter.handledUpdate()).isSameAs(update); + } + + @Test + void unsupportedUpdateReturnsOkWithoutCrashing() { + TelegramWebhookController controller = controllerWithSecret(""); + + ResponseEntity response = controller.receiveUpdate(null, null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(botAdapter.wasCalled()).isTrue(); + assertThat(botAdapter.handledUpdate()).isNull(); + } + + private TelegramWebhookController controllerWithSecret(String webhookSecret) { + return new TelegramWebhookController( + new AtlasProperties(new AtlasProperties.Telegram( + true, + "test-token", + "atlas_test_bot", + "/telegram/webhook", + webhookSecret, + "", + false, + true + )), + botAdapter + ); + } + + private TelegramUpdate textUpdate(String text) { + return new TelegramUpdate( + 100L, + new TelegramUpdate.TelegramMessage( + 10L, + new TelegramUpdate.TelegramChat(42L), + new TelegramUpdate.TelegramUser(7L, "user", "User"), + text + ), + null, + null + ); + } + + private static class RecordingTelegramBotAdapter extends TelegramBotAdapter { + + private boolean called; + private TelegramUpdate handledUpdate; + + RecordingTelegramBotAdapter() { + super(new AtlasProperties(new AtlasProperties.Telegram( + true, + "test-token", + "atlas_test_bot", + "/telegram/webhook", + "", + "", + false, + true + )), null); + } + + @Override + public boolean handleUpdate(TelegramUpdate update) { + called = true; + handledUpdate = update; + return true; + } + + boolean wasCalled() { + return called; + } + + TelegramUpdate handledUpdate() { + return handledUpdate; + } + } +} diff --git a/src/test/java/com/example/atlas/telegram/TelegramWebhookRegistrationServiceTest.java b/src/test/java/com/example/atlas/telegram/TelegramWebhookRegistrationServiceTest.java new file mode 100644 index 0000000..97640db --- /dev/null +++ b/src/test/java/com/example/atlas/telegram/TelegramWebhookRegistrationServiceTest.java @@ -0,0 +1,119 @@ +package com.example.atlas.telegram; + +import com.example.atlas.config.AtlasProperties; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TelegramWebhookRegistrationServiceTest { + + private final RecordingTelegramWebhookClient webhookClient = new RecordingTelegramWebhookClient(); + + @Test + void registrationDisabledDoesNotCallClient() { + TelegramWebhookRegistrationService service = service(properties(false, "https://atlas.example", "/telegram/webhook")); + + service.registerWebhookIfEnabled(); + + assertThat(webhookClient.request()).isNull(); + } + + @Test + void registrationEnabledWithValidConfigCallsSetWebhook() { + TelegramWebhookRegistrationService service = service(properties(true, "https://atlas.example/", "/telegram/webhook")); + + service.registerWebhookIfEnabled(); + + assertThat(webhookClient.request().url()).isEqualTo("https://atlas.example/telegram/webhook"); + assertThat(webhookClient.request().secretToken()).isEqualTo("webhook-secret"); + assertThat(webhookClient.request().dropPendingUpdates()).isTrue(); + } + + @Test + void registrationEnabledWithoutPublicBaseUrlFailsFast() { + TelegramWebhookRegistrationService service = service(properties(true, "", "/telegram/webhook")); + + assertThatThrownBy(service::registerWebhookIfEnabled) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("ATLAS_PUBLIC_BASE_URL"); + } + + @Test + void registrationEnabledWithNonHttpsPublicBaseUrlFailsFast() { + TelegramWebhookRegistrationService service = service(properties(true, "http://atlas.example", "/telegram/webhook")); + + assertThatThrownBy(service::registerWebhookIfEnabled) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("https://"); + } + + @Test + void registrationEnabledWithInvalidWebhookPathFailsFast() { + TelegramWebhookRegistrationService service = service(properties(true, "https://atlas.example", "telegram/webhook")); + + assertThatThrownBy(service::registerWebhookIfEnabled) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("start with /"); + } + + @Test + void telegramApiFailurePropagatesClearException() { + TelegramWebhookRegistrationService service = new TelegramWebhookRegistrationService( + properties(true, "https://atlas.example", "/telegram/webhook"), + new FailingTelegramWebhookClient() + ); + + assertThatThrownBy(service::registerWebhookIfEnabled) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Telegram webhook registration"); + } + + private TelegramWebhookRegistrationService service(AtlasProperties properties) { + return new TelegramWebhookRegistrationService(properties, webhookClient); + } + + private AtlasProperties properties(boolean registerWebhook, String publicBaseUrl, String webhookPath) { + return new AtlasProperties(new AtlasProperties.Telegram( + true, + "test-token", + "atlas_test_bot", + webhookPath, + "webhook-secret", + publicBaseUrl, + registerWebhook, + true + )); + } + + private static class RecordingTelegramWebhookClient extends TelegramWebhookClient { + + private TelegramWebhookRequest request; + + RecordingTelegramWebhookClient() { + super(RestClient.builder().baseUrl("https://api.telegram.org/bottest-token").build()); + } + + @Override + public void setWebhook(TelegramWebhookRequest request) { + this.request = request; + } + + TelegramWebhookRequest request() { + return request; + } + } + + private static class FailingTelegramWebhookClient extends TelegramWebhookClient { + + FailingTelegramWebhookClient() { + super(RestClient.builder().baseUrl("https://api.telegram.org/bottest-token").build()); + } + + @Override + public void setWebhook(TelegramWebhookRequest request) { + throw new IllegalStateException("Telegram webhook registration was rejected by Telegram API."); + } + } +}