diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d233f..9ad3774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## v0.3.2 + +- Removed obsolete frontend-related artifacts from the backend repository +- Removed stale frontend references from docs and CI +- Clarified backend-only scope of the ATLAS repository +- Reorganized repository documentation layout +- Updated roadmap presentation and repository maintenance notes + +## v0.3.1 + +- Stabilized Telegram integration startup behavior +- Added configuration validation for enabled Telegram mode +- Added safe handling for unsupported Telegram updates +- Added robust Telegram message sending behavior +- Added long response splitting for Telegram replies +- Expanded Telegram integration test coverage +- Improved Telegram setup and troubleshooting documentation +- Centralized static Telegram reply templates + +## v0.3.0 + +- Added initial Telegram integration baseline +- Added Telegram webhook receiver foundation +- Added ATLAS command routing through the backend orchestrator +- Added static Telegram reply templates +- Updated README with Telegram bot workflow documentation + ## v0.2.0 - Added Dockerfile for the Spring Boot application diff --git a/README.md b/README.md index ddce816..620645c 100644 --- a/README.md +++ b/README.md @@ -4,236 +4,131 @@

ATLAS

-**ATLAS** — персональная AI-система для управления режимом, спортом, восстановлением, привычками, питанием и прогрессом. +**ATLAS** — backend-first Telegram-система для режима, тренировок, восстановления, привычек, питания и прогресса. -Проект задуман как мультиагентный Telegram-бот: пользователь общается с одним ботом, а внутри системы запросы обрабатывает команда специализированных AI-агентов. +Проект задуман как мультиагентный Telegram-ассистент: пользователь общается с одним ботом, а backend маршрутизирует запросы к специализированным агентам ATLAS. -

Идея

+Фронтенд, лендинг и веб-кабинет не входят в основную дорожную карту этого репозитория и могут развиваться отдельно позже. -ATLAS помогает держать жизнь в ритме: +

Core Scope

-- планировать день и неделю; -- подбирать тренировки под состояние и цель; -- учитывать сон, усталость и восстановление; -- отслеживать привычки; -- помогать с питанием; -- собирать недельную аналитику; -- возвращать пользователя в режим после срывов или перегруза. +- Telegram-first backend-продукт +- Spring Boot приложение с хранением данных в PostgreSQL +- оркестрация агентов для повседневных сценариев пользователя +- безопасная обработка Telegram-команд +- будущая абстракция LLM-провайдера -Главный принцип проекта: **реалистичный план лучше идеального плана, который не будет выполнен**. +

Agents

-

Агенты

- -В первой версии ATLAS включает 7 логических агентов: - -| Агент | Зона ответственности | +| Агент | Ответственность | |---|---| -| **ATLAS Core** | Главный координатор, маршрутизация запросов | -| **ATLAS Coach** | Спорт, тренировки, нагрузка | -| **ATLAS Planner** | Расписание дня и недели | -| **ATLAS Recovery** | Сон, усталость, восстановление | -| **ATLAS Habits** | Привычки, дисциплина, ритм | -| **ATLAS Fuel** | Питание под цель | -| **ATLAS Report** | Недельная аналитика и прогресс | +| ATLAS Core | оркестрация и маршрутизация | +| ATLAS Coach | спорт, тренировки, нагрузка | +| ATLAS Planner | планирование дня и недели | +| ATLAS Recovery | сон, усталость, восстановление | +| ATLAS Habits | привычки, дисциплина, ритм | +| ATLAS Fuel | поддержка питания | +| ATLAS Report | недельная аналитика и прогресс | -

Основные команды

+

Commands

```text -/start — запуск и первичная настройка -/day — план на день -/week — план на неделю -/workout — тренировка на сегодня -/checkin — чек-ин состояния -/recovery — оценка восстановления -/habits — работа с привычками -/food — питание на день -/report — недельный отчёт -/emergency — минимальный план, если день развалился +/start +/day +/week +/workout +/checkin +/recovery +/habits +/food +/report +/emergency ``` -

Архитектура

- -Пользователь взаимодействует с одним Telegram-ботом. -Все агенты существуют внутри backend-приложения как отдельные сервисы или модули. - -```mermaid -flowchart TD - U[Telegram User] --> T[Telegram Bot Adapter] - T --> O[ATLAS Core / Orchestrator] - - O --> C[ATLAS Coach] - O --> P[ATLAS Planner] - O --> R[ATLAS Recovery] - O --> H[ATLAS Habits] - O --> F[ATLAS Fuel] - O --> REP[ATLAS Report] - - O --> M[Memory Service] - M --> DB[(PostgreSQL)] - - DB --> MIG[Flyway Migrations] -``` - -

Стек

- -Базовый стек проекта: - -- **Java 21** -- **Spring Boot** -- **Maven** -- **PostgreSQL** -- **Flyway** -- **JUnit 5** -- **Telegram Bot API** -- **LLM Provider Abstraction** - -На старте проект не привязан к конкретному LLM-провайдеру. -Интеграция с AI должна быть реализована через интерфейс, чтобы в будущем можно было подключить OpenAI, Spring AI, LangChain4j или другой провайдер. - -## Local Startup +

Stack

-ATLAS можно запустить двумя способами: +- Java 21 +- Spring Boot +- Maven +- PostgreSQL +- Flyway +- JUnit 5 +- Telegram Bot API -- native Maven run для разработки Java-приложения; -- Docker Compose run для локальной инфраструктуры с PostgreSQL. +

Local Run

-Native run требует доступный PostgreSQL и переменные окружения для datasource: +Запуск тестов: ```bash -mvn spring-boot:run +mvn test ``` -Telegram-интеграция по умолчанию выключена через `ATLAS_TELEGRAM_ENABLED=false`, поэтому локальный инфраструктурный запуск не требует реального Telegram token. - -## Docker - -Requirements: - -- Docker -- Docker Compose -- Java 21 и Maven для локальных тестов без контейнеров - -Подготовить локальные переменные окружения: +Локальный запуск с выключенной Telegram-интеграцией: ```bash -cp .env.example .env +ATLAS_TELEGRAM_ENABLED=false mvn spring-boot:run ``` -Запустить PostgreSQL и приложение: +Запуск через Docker Compose: ```bash +cp .env.example .env docker compose up --build ``` -Остановить сервисы: +Эндпоинт состояния: -```bash -docker compose down -``` - -Смотреть логи приложения: - -```bash -docker compose logs -f atlas-app +```text +http://localhost:8080/actuator/health ``` -Пересобрать приложение: +Эндпоинт Telegram webhook: -```bash -docker compose up --build atlas-app +```text +POST /telegram/webhook ``` -Удалить контейнеры и локальный PostgreSQL volume: +

Configuration

-```bash -docker compose down -v -``` +Telegram-интеграция по умолчанию выключена для локальной разработки. -Запустить тесты локально: +Переменные, необходимые для включённого Telegram-режима: ```bash -mvn test +ATLAS_TELEGRAM_ENABLED=true +ATLAS_TELEGRAM_BOT_TOKEN= +ATLAS_TELEGRAM_BOT_USERNAME= ``` -Health endpoint доступен по адресу: +Не добавляй реальные секреты в репозиторий. -```text -http://localhost:8080/actuator/health -``` - -

Статус проекта

- -Проект находится на ранней стадии разработки. +

Roadmap

-Текущий фокус версии `v0.2.0`: +Основная продуктовая дорожная карта: ```text -1. Локальная Docker-инфраструктура -2. Docker Compose для приложения и PostgreSQL -3. Environment-based configuration -4. Telegram integration toggle -5. Actuator health endpoint -6. README с Docker workflow -7. Базовый CI workflow +v0.3.0 — реальный Telegram-адаптер +v0.3.1 — стабилизация Telegram-интеграции +v0.4.0 — хранение данных пользователей, сообщений и чек-инов +v0.5.0 — онбординг и диалоговые сценарии +v0.6.0 — LLM-абстракция +v0.6.1 — первая интеграция реального LLM-провайдера ``` -

Принцип маршрутизации

- -ATLAS Core определяет тип запроса и выбирает нужных агентов. - -Примеры: - -| Команда | RequestType | Агент | -|---|---|---| -| `/start` | `START` | `ATLAS Core` | -| `/day` | `DAY_PLAN` | `ATLAS Planner` | -| `/week` | `WEEK_PLAN` | `ATLAS Planner` | -| `/workout` | `WORKOUT` | `ATLAS Coach` | -| `/checkin` | `CHECKIN` | `ATLAS Coach`, `ATLAS Recovery` | -| `/recovery` | `RECOVERY` | `ATLAS Recovery` | -| `/habits` | `HABITS` | `ATLAS Habits` | -| `/food` | `FOOD` | `ATLAS Fuel` | -| `/report` | `REPORT` | `ATLAS Report` | -| `/emergency` | `EMERGENCY` | `ATLAS Habits`, `ATLAS Recovery` | - -Если запрос не подходит ни под одну команду, он обрабатывается как `GENERAL`. - -

Важное уточнение

- -ATLAS не является врачом, диетологом или медицинским специалистом. - -Система не должна: - -- ставить диагнозы; -- назначать лечение; -- рекомендовать тренироваться через боль; -- предлагать экстремальные диеты; -- поощрять опасное снижение веса; -- игнорировать травмы, боль, проблемы с дыханием, сердцем или давлением. - -При серьёзных симптомах бот должен рекомендовать обратиться к врачу или профильному специалисту. - -

Roadmap

+Служебные релизы: ```text -v0.1.0 — skeleton, agents, orchestrator, migrations, README -v0.2.0 — local Docker infrastructure, env config, healthcheck, CI -v0.3.0 — real Telegram bot adapter -v0.4.0 — persistence for messages, profiles, check-ins -v0.5.0 — LLM client abstraction + mock/provider implementation -v0.6.0 — real daily planning and workout flow +v0.3.2 — очистка backend-only репозитория и выравнивание документации ``` -

Цель

+

Safety

-Сделать персональную AI-систему, которая помогает пользователю не просто получать советы, а каждый день принимать более реалистичные решения по режиму, спорту, восстановлению и дисциплине. +ATLAS не является врачом, диетологом или медицинским специалистом. Система не должна ставить диагнозы, назначать лечение, рекомендовать тренироваться через боль, продвигать экстремальные диеты или игнорировать серьёзные симптомы. -ATLAS должен быть не очередным чат-ботом, а спокойной системой координации: +

Docs

-```text -Меньше хаоса. Больше ритма. -``` +- [Границы backend-части](docs/architecture/backend-scope.md)

License

-License will be defined later. +Лицензия будет определена позже. diff --git a/docs/architecture/backend-scope.md b/docs/architecture/backend-scope.md new file mode 100644 index 0000000..f32fa76 --- /dev/null +++ b/docs/architecture/backend-scope.md @@ -0,0 +1,13 @@ +# Backend Scope + +ATLAS is currently a Telegram-first backend product. + +The core repository roadmap focuses on: + +- Telegram adapter and bot flow stabilization +- persistence for users, messages and check-ins +- onboarding and conversational flows +- backend orchestration for ATLAS agents +- LLM provider abstraction + +Frontend surfaces are outside the current core roadmap. A marketing landing page or web dashboard may be built later as a separate product surface, but it should not drive the structure of this backend repository. diff --git a/pom.xml b/pom.xml index 0616204..401b801 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ com.example atlas - 0.2.0 + 0.3.2 ATLAS Personal AI system for rhythm, training, recovery, habits, nutrition and progress. diff --git a/src/main/java/com/example/atlas/agent/core/CoreAgent.java b/src/main/java/com/example/atlas/agent/core/CoreAgent.java index b9bf12a..2669022 100644 --- a/src/main/java/com/example/atlas/agent/core/CoreAgent.java +++ b/src/main/java/com/example/atlas/agent/core/CoreAgent.java @@ -4,6 +4,7 @@ import com.example.atlas.agent.AgentContext; import com.example.atlas.agent.AgentResult; import com.example.atlas.orchestrator.RequestType; +import com.example.atlas.telegram.TelegramReplyTemplates; import org.springframework.stereotype.Component; @Component @@ -22,8 +23,8 @@ public boolean supports(RequestType requestType) { @Override public AgentResult handle(AgentContext context) { String content = switch (context.requestType()) { - case START -> "ATLAS на связи. Начнём спокойно: пришли /checkin, чтобы я понял состояние, или /day для плана на день."; - case GENERAL -> "Я могу помочь с режимом, тренировкой, восстановлением, привычками и питанием. Быстрый старт: /checkin или /day."; + case START -> TelegramReplyTemplates.startWelcome(); + case GENERAL -> TelegramReplyTemplates.generalFallback(); default -> "Маршрут принят ATLAS Core."; }; diff --git a/src/main/java/com/example/atlas/config/AtlasProperties.java b/src/main/java/com/example/atlas/config/AtlasProperties.java index a73d3ed..4aa0a2e 100644 --- a/src/main/java/com/example/atlas/config/AtlasProperties.java +++ b/src/main/java/com/example/atlas/config/AtlasProperties.java @@ -16,5 +16,8 @@ public record Telegram( String botToken, String botUsername ) { + public boolean hasBotToken() { + return botToken != null && !botToken.isBlank(); + } } } diff --git a/src/main/java/com/example/atlas/safety/SafetyGuard.java b/src/main/java/com/example/atlas/safety/SafetyGuard.java new file mode 100644 index 0000000..c073ced --- /dev/null +++ b/src/main/java/com/example/atlas/safety/SafetyGuard.java @@ -0,0 +1,38 @@ +package com.example.atlas.safety; + +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Locale; + +@Component +public class SafetyGuard { + + private static final List RISK_KEYWORDS = List.of( + "pain", + "injury", + "chest pain", + "breathing problems", + "dizziness", + "fainting", + "heart problems", + "blood pressure", + "eating disorder", + "extreme weight loss" + ); + + public boolean requiresSafetyResponse(String message) { + if (message == null || message.isBlank()) { + return false; + } + + String normalized = message.toLowerCase(Locale.ROOT); + return RISK_KEYWORDS.stream().anyMatch(normalized::contains); + } + + public String safetyResponse() { + return "Похоже, сообщение затрагивает симптомы или риски для здоровья. " + + "Снизь интенсивность, не тренируйся через боль и обратись к медицинскому специалисту, " + + "если симптомы сильные, повторяются или связаны с дыханием, сердцем, давлением, головокружением или обмороком."; + } +} diff --git a/src/main/java/com/example/atlas/telegram/TelegramApiClient.java b/src/main/java/com/example/atlas/telegram/TelegramApiClient.java new file mode 100644 index 0000000..89d1029 --- /dev/null +++ b/src/main/java/com/example/atlas/telegram/TelegramApiClient.java @@ -0,0 +1,6 @@ +package com.example.atlas.telegram; + +public interface TelegramApiClient { + + void sendMessage(long chatId, String text); +} diff --git a/src/main/java/com/example/atlas/telegram/TelegramBotAdapter.java b/src/main/java/com/example/atlas/telegram/TelegramBotAdapter.java index 8206617..2f1ebf2 100644 --- a/src/main/java/com/example/atlas/telegram/TelegramBotAdapter.java +++ b/src/main/java/com/example/atlas/telegram/TelegramBotAdapter.java @@ -1,6 +1,9 @@ package com.example.atlas.telegram; import com.example.atlas.config.AtlasProperties; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -8,6 +11,8 @@ @ConditionalOnProperty(prefix = "atlas.telegram", name = "enabled", havingValue = "true") public class TelegramBotAdapter { + private static final Logger log = LoggerFactory.getLogger(TelegramBotAdapter.class); + private final AtlasProperties properties; private final TelegramUpdateHandler updateHandler; @@ -20,7 +25,16 @@ public String botUsername() { return properties.telegram().botUsername(); } + public boolean handleUpdate(TelegramUpdate update) { + return updateHandler.handleUpdate(update); + } + public String handleTextMessage(String text) { return updateHandler.handleTextMessage(text); } + + @PostConstruct + void logStartup() { + log.info("Telegram integration is enabled for bot username '{}'", botUsername()); + } } diff --git a/src/main/java/com/example/atlas/telegram/TelegramBotApiClient.java b/src/main/java/com/example/atlas/telegram/TelegramBotApiClient.java new file mode 100644 index 0000000..d353ae1 --- /dev/null +++ b/src/main/java/com/example/atlas/telegram/TelegramBotApiClient.java @@ -0,0 +1,33 @@ +package com.example.atlas.telegram; + +import com.example.atlas.config.AtlasProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.Map; + +@Component +@ConditionalOnProperty(prefix = "atlas.telegram", name = "enabled", havingValue = "true") +public class TelegramBotApiClient implements TelegramApiClient { + + private final RestClient restClient; + + public TelegramBotApiClient(AtlasProperties properties, RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder + .baseUrl("https://api.telegram.org/bot" + properties.telegram().botToken()) + .build(); + } + + @Override + public void sendMessage(long chatId, String text) { + restClient.post() + .uri("/sendMessage") + .body(Map.of( + "chat_id", chatId, + "text", text + )) + .retrieve() + .toBodilessEntity(); + } +} diff --git a/src/main/java/com/example/atlas/telegram/TelegramConfigurationValidator.java b/src/main/java/com/example/atlas/telegram/TelegramConfigurationValidator.java new file mode 100644 index 0000000..143eb8a --- /dev/null +++ b/src/main/java/com/example/atlas/telegram/TelegramConfigurationValidator.java @@ -0,0 +1,27 @@ +package com.example.atlas.telegram; + +import com.example.atlas.config.AtlasProperties; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(prefix = "atlas.telegram", name = "enabled", havingValue = "true") +public class TelegramConfigurationValidator { + + private final AtlasProperties properties; + + public TelegramConfigurationValidator(AtlasProperties properties) { + this.properties = properties; + } + + @PostConstruct + void validate() { + if (!properties.telegram().hasBotToken()) { + throw new IllegalStateException( + "ATLAS Telegram integration is enabled, but atlas.telegram.bot-token is missing. " + + "Set ATLAS_TELEGRAM_BOT_TOKEN or disable Telegram with ATLAS_TELEGRAM_ENABLED=false." + ); + } + } +} diff --git a/src/main/java/com/example/atlas/telegram/TelegramMessageSender.java b/src/main/java/com/example/atlas/telegram/TelegramMessageSender.java new file mode 100644 index 0000000..ce39776 --- /dev/null +++ b/src/main/java/com/example/atlas/telegram/TelegramMessageSender.java @@ -0,0 +1,84 @@ +package com.example.atlas.telegram; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +@ConditionalOnProperty(prefix = "atlas.telegram", name = "enabled", havingValue = "true") +public class TelegramMessageSender { + + static final int MAX_TEXT_CHUNK_SIZE = 3900; + + private static final Logger log = LoggerFactory.getLogger(TelegramMessageSender.class); + + private final TelegramApiClient telegramApiClient; + + public TelegramMessageSender(TelegramApiClient telegramApiClient) { + this.telegramApiClient = telegramApiClient; + } + + public void sendText(long chatId, String text) { + for (String chunk : splitText(text)) { + try { + telegramApiClient.sendMessage(chatId, chunk); + } catch (RuntimeException exception) { + log.warn( + "Telegram sendMessage failed for chat {} with {}", + chatId, + exception.getClass().getSimpleName() + ); + } + } + } + + public List splitText(String text) { + String value = text == null || text.isBlank() ? TelegramReplyTemplates.generalFallback() : text; + if (value.length() <= MAX_TEXT_CHUNK_SIZE) { + return List.of(value); + } + + List chunks = new ArrayList<>(); + int start = 0; + while (start < value.length()) { + int end = Math.min(start + MAX_TEXT_CHUNK_SIZE, value.length()); + if (end == value.length()) { + chunks.add(value.substring(start)); + break; + } + + int splitAt = findSplitBoundary(value, start, end); + chunks.add(value.substring(start, splitAt).stripTrailing()); + + start = splitAt; + while (start < value.length() && Character.isWhitespace(value.charAt(start))) { + start++; + } + } + + return chunks; + } + + private int findSplitBoundary(String text, int start, int end) { + int paragraphBoundary = text.lastIndexOf("\n\n", end); + if (paragraphBoundary > start) { + return paragraphBoundary; + } + + int lineBoundary = text.lastIndexOf('\n', end); + if (lineBoundary > start) { + return lineBoundary; + } + + int wordBoundary = text.lastIndexOf(' ', end); + if (wordBoundary > start) { + return wordBoundary; + } + + return end; + } +} diff --git a/src/main/java/com/example/atlas/telegram/TelegramReplyTemplates.java b/src/main/java/com/example/atlas/telegram/TelegramReplyTemplates.java new file mode 100644 index 0000000..198cefe --- /dev/null +++ b/src/main/java/com/example/atlas/telegram/TelegramReplyTemplates.java @@ -0,0 +1,20 @@ +package com.example.atlas.telegram; + +public final class TelegramReplyTemplates { + + private TelegramReplyTemplates() { + } + + public static String startWelcome() { + return "ATLAS на связи. Я помогаю держать режим, тренировки, восстановление, привычки и питание в реалистичном ритме. " + + "Начни с /checkin, чтобы я понял состояние, или /day для плана на день."; + } + + public static String generalFallback() { + return "Я могу помочь с режимом, тренировкой, восстановлением, привычками и питанием. Быстрый старт: /checkin или /day."; + } + + public static String unsupportedContent() { + return "Я пока обрабатываю только текстовые сообщения и команды."; + } +} diff --git a/src/main/java/com/example/atlas/telegram/TelegramUpdate.java b/src/main/java/com/example/atlas/telegram/TelegramUpdate.java new file mode 100644 index 0000000..a679714 --- /dev/null +++ b/src/main/java/com/example/atlas/telegram/TelegramUpdate.java @@ -0,0 +1,32 @@ +package com.example.atlas.telegram; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record TelegramUpdate( + @JsonProperty("update_id") Long updateId, + TelegramMessage message, + @JsonProperty("edited_message") TelegramMessage editedMessage, + @JsonProperty("callback_query") TelegramCallbackQuery callbackQuery +) { + + public record TelegramMessage( + @JsonProperty("message_id") Long messageId, + TelegramChat chat, + TelegramUser from, + String text + ) { + } + + public record TelegramChat(Long id) { + } + + public record TelegramUser( + Long id, + String username, + @JsonProperty("first_name") String firstName + ) { + } + + public record TelegramCallbackQuery(String id) { + } +} diff --git a/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java b/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java index 995e385..80c6c0e 100644 --- a/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java +++ b/src/main/java/com/example/atlas/telegram/TelegramUpdateHandler.java @@ -1,6 +1,9 @@ package com.example.atlas.telegram; import com.example.atlas.orchestrator.OrchestratorService; +import com.example.atlas.safety.SafetyGuard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -8,13 +11,49 @@ @ConditionalOnProperty(prefix = "atlas.telegram", name = "enabled", havingValue = "true") public class TelegramUpdateHandler { + private static final Logger log = LoggerFactory.getLogger(TelegramUpdateHandler.class); + private final OrchestratorService orchestratorService; + private final TelegramMessageSender messageSender; + private final SafetyGuard safetyGuard; - public TelegramUpdateHandler(OrchestratorService orchestratorService) { + public TelegramUpdateHandler( + OrchestratorService orchestratorService, + TelegramMessageSender messageSender, + SafetyGuard safetyGuard + ) { this.orchestratorService = orchestratorService; + this.messageSender = messageSender; + this.safetyGuard = safetyGuard; + } + + public boolean handleUpdate(TelegramUpdate update) { + if (update == null || update.message() == null) { + log.debug("Ignoring unsupported Telegram update without a text message"); + return false; + } + + TelegramUpdate.TelegramMessage message = update.message(); + if (message.chat() == null || message.chat().id() == null) { + log.debug("Ignoring Telegram message without chat id"); + return false; + } + + if (message.text() == null || message.text().isBlank()) { + log.debug("Ignoring Telegram non-text or blank message"); + return false; + } + + String response = handleTextMessage(message.text()); + messageSender.sendText(message.chat().id(), response); + return true; } public String handleTextMessage(String text) { + if (safetyGuard.requiresSafetyResponse(text)) { + return safetyGuard.safetyResponse(); + } + return orchestratorService.route(text).content(); } } diff --git a/src/main/java/com/example/atlas/telegram/TelegramWebhookController.java b/src/main/java/com/example/atlas/telegram/TelegramWebhookController.java new file mode 100644 index 0000000..ba8e24d --- /dev/null +++ b/src/main/java/com/example/atlas/telegram/TelegramWebhookController.java @@ -0,0 +1,26 @@ +package com.example.atlas.telegram; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.ResponseEntity; +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; + +@RestController +@RequestMapping("/telegram") +@ConditionalOnProperty(prefix = "atlas.telegram", name = "enabled", havingValue = "true") +public class TelegramWebhookController { + + private final TelegramBotAdapter botAdapter; + + public TelegramWebhookController(TelegramBotAdapter botAdapter) { + this.botAdapter = botAdapter; + } + + @PostMapping("/webhook") + public ResponseEntity receiveUpdate(@RequestBody(required = false) TelegramUpdate update) { + botAdapter.handleUpdate(update); + return ResponseEntity.ok().build(); + } +} diff --git a/src/test/java/com/example/atlas/AtlasApplicationContextTest.java b/src/test/java/com/example/atlas/AtlasApplicationContextTest.java index c69a28f..3288f2b 100644 --- a/src/test/java/com/example/atlas/AtlasApplicationContextTest.java +++ b/src/test/java/com/example/atlas/AtlasApplicationContextTest.java @@ -1,6 +1,7 @@ package com.example.atlas; import com.example.atlas.telegram.TelegramBotAdapter; +import com.example.atlas.telegram.TelegramMessageSender; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.boot.SpringApplication; @@ -8,6 +9,7 @@ import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class AtlasApplicationContextTest { @@ -27,6 +29,49 @@ void contextLoadsWithTelegramDisabled() { try (ConfigurableApplicationContext context = application.run()) { assertThatThrownBy(() -> context.getBean(TelegramBotAdapter.class)) .isInstanceOf(NoSuchBeanDefinitionException.class); + assertThatThrownBy(() -> context.getBean(TelegramMessageSender.class)) + .isInstanceOf(NoSuchBeanDefinitionException.class); } } + + @Test + void contextLoadsWithTelegramEnabledAndToken() { + SpringApplication application = new SpringApplication(AtlasApplication.class); + application.setDefaultProperties(baseTestProperties()); + + try (ConfigurableApplicationContext context = application.run( + "--atlas.telegram.enabled=true", + "--atlas.telegram.bot-token=test-token", + "--atlas.telegram.bot-username=atlas_test_bot" + )) { + assertThat(context.getBean(TelegramBotAdapter.class).botUsername()) + .isEqualTo("atlas_test_bot"); + } + } + + @Test + void contextFailsWhenTelegramEnabledWithoutToken() { + SpringApplication application = new SpringApplication(AtlasApplication.class); + application.setDefaultProperties(baseTestProperties()); + + assertThatThrownBy(() -> application.run( + "--atlas.telegram.enabled=true", + "--atlas.telegram.bot-token=", + "--atlas.telegram.bot-username=atlas_test_bot" + )) + .hasStackTraceContaining("ATLAS Telegram integration is enabled") + .hasStackTraceContaining("ATLAS_TELEGRAM_BOT_TOKEN"); + } + + private Map baseTestProperties() { + java.util.HashMap values = new java.util.HashMap<>(); + values.put("spring.main.web-application-type", "none"); + values.put( + "spring.autoconfigure.exclude", + "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," + + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration," + + "org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration" + ); + return values; + } } diff --git a/src/test/java/com/example/atlas/telegram/TelegramMessageSenderTest.java b/src/test/java/com/example/atlas/telegram/TelegramMessageSenderTest.java new file mode 100644 index 0000000..f658211 --- /dev/null +++ b/src/test/java/com/example/atlas/telegram/TelegramMessageSenderTest.java @@ -0,0 +1,62 @@ +package com.example.atlas.telegram; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class TelegramMessageSenderTest { + + @Test + void shortResponseStaysAsOneMessage() { + RecordingTelegramApiClient apiClient = new RecordingTelegramApiClient(); + TelegramMessageSender sender = new TelegramMessageSender(apiClient); + + sender.sendText(42L, "short reply"); + + assertThat(apiClient.sentTexts()).containsExactly("short reply"); + } + + @Test + void longResponseSplitsIntoChunksWithinLimit() { + TelegramMessageSender sender = new TelegramMessageSender(new RecordingTelegramApiClient()); + String longText = "a".repeat(TelegramMessageSender.MAX_TEXT_CHUNK_SIZE) + + "\n\n" + + "b".repeat(200); + + List chunks = sender.splitText(longText); + + assertThat(chunks).hasSize(2); + assertThat(chunks).allSatisfy(chunk -> + assertThat(chunk.length()).isLessThanOrEqualTo(TelegramMessageSender.MAX_TEXT_CHUNK_SIZE) + ); + assertThat(String.join("\n\n", chunks)).contains("a".repeat(100), "b".repeat(100)); + } + + @Test + void sendErrorsDoNotPropagate() { + TelegramMessageSender sender = new TelegramMessageSender((chatId, text) -> { + throw new IllegalStateException("telegram transport failed"); + }); + + assertThatCode(() -> sender.sendText(42L, "reply")) + .doesNotThrowAnyException(); + } + + private static class RecordingTelegramApiClient implements TelegramApiClient { + + private final List sentTexts = new ArrayList<>(); + + @Override + public void sendMessage(long chatId, String text) { + sentTexts.add(text); + } + + List sentTexts() { + return sentTexts; + } + } +} diff --git a/src/test/java/com/example/atlas/telegram/TelegramUpdateHandlerTest.java b/src/test/java/com/example/atlas/telegram/TelegramUpdateHandlerTest.java new file mode 100644 index 0000000..de109b1 --- /dev/null +++ b/src/test/java/com/example/atlas/telegram/TelegramUpdateHandlerTest.java @@ -0,0 +1,134 @@ +package com.example.atlas.telegram; + +import com.example.atlas.agent.coach.CoachAgent; +import com.example.atlas.agent.core.CoreAgent; +import com.example.atlas.agent.fuel.FuelAgent; +import com.example.atlas.agent.habits.HabitsAgent; +import com.example.atlas.agent.planner.PlannerAgent; +import com.example.atlas.agent.recovery.RecoveryAgent; +import com.example.atlas.agent.report.ReportAgent; +import com.example.atlas.orchestrator.OrchestratorService; +import com.example.atlas.safety.SafetyGuard; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TelegramUpdateHandlerTest { + + private final RecordingTelegramApiClient apiClient = new RecordingTelegramApiClient(); + private final TelegramUpdateHandler handler = new TelegramUpdateHandler( + orchestratorService(), + new TelegramMessageSender(apiClient), + new SafetyGuard() + ); + + @Test + void supportedCommandTextReachesOrchestrator() { + boolean handled = handler.handleUpdate(textUpdate("/workout")); + + assertThat(handled).isTrue(); + assertThat(apiClient.sentTexts()).singleElement().asString() + .contains("Тренировка на сегодня"); + } + + @Test + void dayCommandReturnsPlannerResponse() { + boolean handled = handler.handleUpdate(textUpdate("/day")); + + assertThat(handled).isTrue(); + assertThat(apiClient.sentTexts()).singleElement().asString() + .contains("План дня"); + } + + @Test + void simpleCommandReturnsExpectedResult() { + assertThat(handler.handleTextMessage("/start")) + .contains("ATLAS на связи"); + } + + @Test + void safetyKeywordReturnsSafeRecommendation() { + assertThat(handler.handleTextMessage("I have chest pain during workout")) + .contains("Снизь интенсивность") + .contains("медицинскому специалисту"); + } + + @Test + void unsupportedUpdateWithoutMessageIsIgnored() { + boolean handled = handler.handleUpdate(new TelegramUpdate( + 100L, + null, + new TelegramUpdate.TelegramMessage( + 10L, + new TelegramUpdate.TelegramChat(42L), + null, + "edited" + ), + null + )); + + assertThat(handled).isFalse(); + assertThat(apiClient.sentTexts()).isEmpty(); + } + + @Test + void blankTextMessageIsIgnored() { + boolean handled = handler.handleUpdate(new TelegramUpdate( + 100L, + new TelegramUpdate.TelegramMessage( + 10L, + new TelegramUpdate.TelegramChat(42L), + new TelegramUpdate.TelegramUser(7L, "user", "User"), + " " + ), + null, + null + )); + + assertThat(handled).isFalse(); + assertThat(apiClient.sentTexts()).isEmpty(); + } + + 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 OrchestratorService orchestratorService() { + return new OrchestratorService(List.of( + new CoreAgent(), + new CoachAgent(), + new PlannerAgent(), + new RecoveryAgent(), + new HabitsAgent(), + new FuelAgent(), + new ReportAgent() + )); + } + + private static class RecordingTelegramApiClient implements TelegramApiClient { + + private final List sentTexts = new ArrayList<>(); + + @Override + public void sendMessage(long chatId, String text) { + sentTexts.add(text); + } + + List sentTexts() { + return sentTexts; + } + } +}