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;
+ }
+ }
+}