Compare commits

11 Commits
Author SHA1 Message Date
Sanders ad0cd40099 Add proxy env 2026-05-13 16:17:44 +03:00
Sanders 43c12b96e3 Fixes 2025-04-15 22:12:43 +03:00
Sanders 97bfe3dc50 Fixes 2025-04-15 01:45:04 +03:00
Sanders 3fa0fe1996 Fixes 2025-04-15 01:22:28 +03:00
Sanders 4f92087d9f Fixes 2025-03-26 23:13:26 +03:00
Sanders 9056b4dbd9 (no commit message provided) 2025-03-26 22:45:10 +03:00
Sanders 8b11ae080e (no commit message provided) 2025-03-26 22:41:27 +03:00
Sanders a5ec9c38d2 feat: Allow admins to configure AI model via profile 2025-03-26 22:37:11 +03:00
Sanders 202a091d6e Fixes 2025-03-26 22:27:24 +03:00
Sanders c0b07df439 Fix book listing 2025-03-25 18:46:02 +03:00
Sanders f21e3ec2b2 feat: Add HTTP proxy support for RestTemplate 2025-03-11 23:55:42 +03:00
19 changed files with 636 additions and 115 deletions
+2
View File
@@ -7,6 +7,8 @@
<env name="DB_URL" value="jdbc:postgresql://192.168.0.10:5432/telegabot" />
<env name="DB_USER" value="postgres" />
<env name="OPENROUTER_API_KEY" value="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" />
<env name="PROXY_HOST" value="192.168.0.5" />
<env name="PROXY_PORT" value="1984" />
</envs>
<module name="TelegaBot.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="ru.cathub.telegabot.TelegaBotApplication" />
+1 -1
View File
@@ -9,7 +9,7 @@ COPY gradle ./gradle
RUN ./gradlew clean build -x test
# Запуск приложения
FROM openjdk:21-jdk-slim
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
@@ -17,16 +17,21 @@ import static ru.cathub.telegabot.utils.Constants.*;
public class KeyboardHelper {
public static ReplyKeyboardMarkup getProfileMenuKeyboard() {
// Изменена сигнатура метода
public static ReplyKeyboardMarkup getProfileMenuKeyboard(boolean isAdmin) {
List<KeyboardRow> keyboardRows = new ArrayList<>();
KeyboardRow row1 = new KeyboardRow();
row1.add(new KeyboardButton(SET_BOOKS_GOAL));
// Добавляем кнопку редактирования модели ИИ только для админа
if (isAdmin) {
row1.add(new KeyboardButton(EDIT_AI_MODEL));
}
KeyboardRow row2 = new KeyboardRow();
row2.add(new KeyboardButton(BACK));
//keyboardRows.add(row1);
keyboardRows.add(row1);
keyboardRows.add(row2);
ReplyKeyboardMarkup keyboardMarkup = new ReplyKeyboardMarkup(keyboardRows);
@@ -89,6 +94,17 @@ public class KeyboardHelper {
return keyboardMarkup;
}
public static InlineKeyboardMarkup getEditTargerBooksKeyboard(){
List<InlineKeyboardRow> rows = new ArrayList<>();
InlineKeyboardRow navRow = new InlineKeyboardRow();
navRow.add(InlineKeyboardButton.builder()
.text("Изменить")
.callbackData("EDIT_TARGET")
.build());
rows.add(navRow);
return new InlineKeyboardMarkup(rows);
}
public static InlineKeyboardMarkup getBookListKeyboard(int currentPage, boolean hasNext, Page<Book> books, boolean forEdit) {
@@ -1,11 +1,15 @@
package ru.cathub.telegabot.configuration;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import java.net.Proxy;
import java.net.InetSocketAddress;
@Configuration
@EnableConfigurationProperties
@@ -14,11 +18,22 @@ import org.springframework.web.client.RestTemplate;
public class OpenRouterConfig {
private String key;
private String url;
private String model;
@Value("${proxy.host:}")
private String proxyHost;
@Value("${proxy.port:0}")
private int proxyPort;
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
if (proxyHost != null && !proxyHost.isBlank() && proxyPort > 0) {
factory.setProxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)));
}
return new RestTemplate(factory);
}
public String getKey() {
@@ -28,8 +43,4 @@ public class OpenRouterConfig {
public String getUrl() {
return url;
}
public String getModel() {
return model;
}
}
@@ -1,5 +1,6 @@
package ru.cathub.telegabot.configuration;
import okhttp3.OkHttpClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -9,10 +10,20 @@ import org.telegram.telegrambots.meta.generics.TelegramClient;
@Configuration
public class TelegramConfig {
@Value("${proxy.host:}")
private String proxyHost;
@Value("${proxy.port:0}")
private int proxyPort;
@Bean
public TelegramClient telegramClient(@Value("${bot.token}") String token) {
return new OkHttpTelegramClient(token);
OkHttpClient.Builder builder = new OkHttpClient.Builder();
if (proxyHost != null && !proxyHost.isBlank() && proxyPort > 0) {
builder.proxy(new java.net.Proxy(java.net.Proxy.Type.HTTP, new java.net.InetSocketAddress(proxyHost, proxyPort)));
}
OkHttpClient okHttpClient = builder.build();
return new OkHttpTelegramClient(okHttpClient, token);
}
}
@@ -0,0 +1,29 @@
package ru.cathub.telegabot.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
// Указываем имя таблицы и схему из application.properties
@Table(name = "bot_settings", schema = "telegabot")
public class BotSetting {
@Id
@Column(name = "setting_key", nullable = false, unique = true)
private String settingKey; // Ключ настройки (например, "ENABLE_WELCOME_MESSAGE")
@Column(name = "setting_value", columnDefinition = "TEXT") // Используем TEXT для гибкости
private String settingValue; // Значение настройки
// Можно добавить поле для описания, если нужно
// @Column(name = "description")
// private String description;
}
@@ -21,7 +21,9 @@ public class BotUser {
USER, ADMIN }
public enum WorkingMode {
BOOK,TORRENT,AI,EDIT_PROFILE,NO_MODE,AI_FILTER,AI_SUGGEST
BOOK, TORRENT, AI, EDIT_PROFILE, NO_MODE, AI_FILTER, AI_SUGGEST,
// Новые состояния для редактирования профиля
EDIT_PROFILE_BOOKS_GOAL, EDIT_PROFILE_AI_MODEL
}
private Integer booksToRead;
@@ -2,12 +2,12 @@ package ru.cathub.telegabot.model;
public enum TextRole {
DEFAULT("Ты книжный ассистент в Telegram-боте. Отвечай, используй эмоджи, разметку Markdown (*для\n" +
" выделения*, _для наклонного_). Отвечай ТОЛЬКО на русском языке."),
" выделения*, _для наклонного_). Помни, что эти символы нельзя использовать просто так, не для выделения текста. Отвечай ТОЛЬКО на русском языке."),
SUGGESTER("Ты книжный ассистент в Telegram-боте, который помогает находить новые книги. Отвечай, используй эмоджи, разметку Markdown (*для\n" +
" выделения*, _для наклонного_). Тебе будет передан список книг пользователя и ты должен проанализировать\n" +
" выделения*, _для наклонного_). Помни, что эти символы нельзя использовать просто так, не для выделения текста. Тебе будет передан список книг пользователя и ты должен проанализировать\n" +
" этот список, и далее отвечать операясь на предпочтения пользоватья из этого списка"),
FILTERER("Ты книжный ассистент в Telegram-боте, который фильтрует списки. Отвечай, используй эмоджи, разметку Markdown (*для\n" +
" выделения*, _для наклонного_). Тебе будет передан список книг пользователя и ты должен вывести книги,\n"+
" выделения*, _для наклонного_). Помни, что эти символы нельзя использовать просто так, не для выделения текста.Тебе будет передан список книг пользователя и ты должен вывести книги,\n"+
" которые удовлетворяют запросу пользователя из этого списка. Если таких книг нет, то отвечай, что книги не найдены"),
RECOMMENDER("Рекомендуй книги на основе предпочтений");
@@ -0,0 +1,11 @@
package ru.cathub.telegabot.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.cathub.telegabot.model.BotSetting;
@Repository
public interface BotSettingRepository extends JpaRepository<BotSetting, String> {
// JpaRepository уже предоставляет findById (для получения по ключу), save (для сохранения/обновления), findAll и т.д.
// Дополнительные методы пока не требуются.
}
@@ -0,0 +1,49 @@
package ru.cathub.telegabot.service;
import ru.cathub.telegabot.exception.ServiceException;
import ru.cathub.telegabot.model.BotUser;
import java.util.Map;
import java.util.Optional;
public interface BotSettingService {
/**
* Получает значение настройки по ключу.
* @param key Ключ настройки.
* @return Optional со значением, если найдено, иначе Optional.empty().
*/
Optional<String> getSettingValue(String key);
/**
* Получает значение настройки по ключу или значение по умолчанию.
* @param key Ключ настройки.
* @param defaultValue Значение по умолчанию.
* @return Значение настройки или defaultValue.
*/
String getSettingValueOrDefault(String key, String defaultValue);
/**
* Устанавливает значение настройки. Требует прав администратора.
* @param key Ключ настройки.
* @param value Новое значение.
* @param requestingUser Пользователь, выполняющий операцию.
* @throws ServiceException если у пользователя нет прав администратора.
*/
void setSettingValue(String key, String value, BotUser requestingUser) throws ServiceException;
/**
* Получает все настройки в виде Map. Требует прав администратора.
* @param requestingUser Пользователь, выполняющий операцию.
* @return Map, где ключ - settingKey, значение - settingValue.
* @throws ServiceException если у пользователя нет прав администратора.
*/
Map<String, String> getAllSettings(BotUser requestingUser) throws ServiceException;
/**
* Проверяет, является ли пользователь администратором.
* @param user Пользователь для проверки.
* @return true, если пользователь администратор, иначе false.
*/
boolean isAdmin(BotUser user);
}
@@ -7,5 +7,11 @@ public interface ProfileService {
void enterProfileEditingMode(BotUser user);
void handleProfileUpdate(BotUser user, String input) throws ServiceException;
void handleBooksGoalUpdate(BotUser user);
// Метод для инициации запроса на изменение цели по книгам (переименован)
void handleBooksGoalUpdateInitiation(BotUser user);
// Новый метод для инициации запроса на изменение модели ИИ
void handleAiModelUpdateInitiation(BotUser user) throws ServiceException;
void handleBooksGoalShow(BotUser user);
}
@@ -78,10 +78,11 @@ public class BookCreationServiceImpl implements BookCreationService {
case ADDING_RATING -> {
int rating = validateAndParseRating(text);
cache.setRating(rating);
cache.setCreationState(BookCreationCache.CreationState.ADDING_DATE);
//cache.setCreationState(BookCreationCache.CreationState.ADDING_DATE);
bookCacheService.updateCache(user.getId(), cache);
telegramClientService.sendMessage(user, "📅 Введите дату прочтения (в формате ДД.ММ.ГГГГ): \n" +
"✅ Для указания текущей даты введите букву \"т\"");
createAndSaveBookFromCache(user, cache);
//telegramClientService.sendMessage(user, "📅 Введите дату прочтения (в формате ДД.ММ.ГГГГ): \n" +
// "✅ Для указания текущей даты введите букву \"т\"");
}
case ADDING_DATE -> {
@@ -128,7 +129,7 @@ public class BookCreationServiceImpl implements BookCreationService {
}
private Book createBookFromCache(BookCreationCache cache, BotUser user) {
return Book.builder().title(cache.getTitle()).author(cache.getAuthor()).rating(cache.getRating()).readDate(cache.getReadDate()).botUser(user).build();
return Book.builder().title(cache.getTitle()).author(cache.getAuthor()).rating(cache.getRating()).readDate(Date.valueOf(LocalDate.now())).botUser(user).build();
}
private void createAndSaveBookFromCache(BotUser user, BookCreationCache cache) {
@@ -142,6 +143,9 @@ public class BookCreationServiceImpl implements BookCreationService {
int weeksLeft = Math.toIntExact(ChronoUnit.WEEKS.between(today, endOfYear));
BotUser freshUser = userRepository.findById(user.getId()).orElseThrow();
bookRepository.save(book);
if (freshUser.getBooksToRead() != null) {
int target = freshUser.getBooksToRead();
int read = freshUser.getBooks() != null ? freshUser.getBooks().size() : 0;
@@ -153,7 +157,7 @@ public class BookCreationServiceImpl implements BookCreationService {
} else if (left == 0) {
stats = "";
try {
String prompt = String.format("Придумай креативное поздравление с тем, что пользователь выполнил большое достижение по прочтению цель по проятению %d книг за год", freshUser.getBooksToRead());
String prompt = String.format("Придумай креативное поздравление с тем, что пользователь выполнил большое достижение по прочтению цель по прочтению %d книг за год", freshUser.getBooksToRead());
String congrat = openRouterService.getChatResponse(prompt, TextRole.DEFAULT);
log.info("Congratulation target generated: {}", congrat);
telegramClientService.sendMessageWithMarkdown(user, "🎉 *Поздравляю с прочтением\\!*\n\n" + escapeNonMarkdown(congrat));
@@ -166,14 +170,13 @@ public class BookCreationServiceImpl implements BookCreationService {
}
telegramClientService.sendMessageWithMarkdown(user, message + stats);
}
bookRepository.save(book);
user.setLastReadDate(new Date(System.currentTimeMillis()));
user.setLastNotificationDate(null);
userRepository.save(user);
try {
String prompt = String.format("Придумай креативное поздравление с прочтением книги \"%s\" от автора \"%s\". Учитывай что пользователь поставил оценку %d/10. ", cache.getTitle(), cache.getAuthor(), cache.getRating());
String prompt = String.format("Придумай креативное поздравление с прочтением книги \"%s\" от автора \"%s\". ", cache.getTitle(), cache.getAuthor());
String congrat = openRouterService.getChatResponse(prompt, TextRole.DEFAULT);
log.info("Congratulation generated: {}", congrat);
telegramClientService.sendMessageWithMarkdown(user, "🎉 *Поздравляю с прочтением\\!*\n\n" + escapeNonMarkdown(congrat));
@@ -134,13 +134,14 @@ public class BookManageServiceImpl implements BookManageService {
private String getWelcomeMessage() {
return "✨ *Добро пожаловать в Бок Книжник\\!* 📚\n" +
return "✨ *Добро пожаловать в Бот Книжник\\!* 📚\n" +
"_Ваш персональный помощник для управления библиотекой_\n\n" +
"Чтобы начать, отправьте:\n" +
"👉 /start — активировать бота и открыть меню\n\n" +
"**С нами вы сможете:**\n" +
"🔸 Добавлять книги с рейтингом\n" +
"🔸 Просматривать свою коллекцию\n" +
"🔸 Устанавливать цель на год\n" +
"🔸 Редактировать записи\n" +
"🔸 Сортировать по рейтингу\n\n" +
"📌 *Не забывайте: всегда можно вернуться в главное меню командой* /start\n\n" +
@@ -0,0 +1,81 @@
package ru.cathub.telegabot.service.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.cathub.telegabot.exception.ServiceException;
import ru.cathub.telegabot.model.BotSetting;
import ru.cathub.telegabot.model.BotUser;
import ru.cathub.telegabot.repository.BotSettingRepository;
import ru.cathub.telegabot.service.BotSettingService;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class BotSettingServiceImpl implements BotSettingService {
private final BotSettingRepository settingRepository;
@Override
@Transactional(readOnly = true) // Указываем, что метод только для чтения
public Optional<String> getSettingValue(String key) {
Optional<BotSetting> setting = settingRepository.findById(key);
log.trace("Getting setting '{}'. Found: {}", key, setting.isPresent()); // Используем trace для менее важных логов
return setting.map(BotSetting::getSettingValue);
}
@Override
@Transactional(readOnly = true) // Указываем, что метод только для чтения
public String getSettingValueOrDefault(String key, String defaultValue) {
String value = getSettingValue(key).orElse(defaultValue);
log.trace("Getting setting '{}' with default '{}'. Result: '{}'", key, defaultValue, value);
return value;
}
@Override
@Transactional
public void setSettingValue(String key, String value, BotUser requestingUser) throws ServiceException {
if (!isAdmin(requestingUser)) {
log.warn("User {} (ID: {}) attempted to set setting '{}' without admin rights.",
requestingUser.getChatId(), requestingUser.getId(), key); // Логируем попытку неавторизованного доступа
throw new ServiceException("У вас нет прав для изменения настроек бота.");
}
BotSetting setting = settingRepository.findById(key)
.orElse(new BotSetting(key, null)); // Создаем новую, если не найдена
String oldValue = setting.getSettingValue();
log.info("Admin User {} (ID: {}) is setting '{}' from '{}' to '{}'",
requestingUser.getChatId(), requestingUser.getId(), key, oldValue, value);
setting.setSettingValue(value);
settingRepository.save(setting);
}
@Override
@Transactional(readOnly = true) // Указываем, что метод только для чтения
public Map<String, String> getAllSettings(BotUser requestingUser) throws ServiceException {
if (!isAdmin(requestingUser)) {
log.warn("User {} (ID: {}) attempted to list all settings without admin rights.",
requestingUser.getChatId(), requestingUser.getId());
throw new ServiceException("У вас нет прав для просмотра настроек бота.");
}
log.debug("Admin User {} (ID: {}) requested all settings.", requestingUser.getChatId(), requestingUser.getId());
return settingRepository.findAll().stream()
.collect(Collectors.toMap(BotSetting::getSettingKey, setting -> Optional.ofNullable(setting.getSettingValue()).orElse(""))); // Собираем в Map, заменяя null на пустую строку
}
@Override
public boolean isAdmin(BotUser user) {
// Используем существующее поле UserType
boolean isAdmin = user != null && user.getUserType() == BotUser.UserType.ADMIN;
log.trace("Checking admin status for user ID {}: {}", user != null ? user.getId() : "null", isAdmin);
return isAdmin;
}
}
@@ -4,14 +4,19 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.http.*; // Импортируем MediaType и HttpStatus
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
// import org.springframework.http.client.SimpleClientHttpRequestFactory; // <-- Удалить, если RestTemplate инжектируется
// import java.net.Proxy; // <-- Удалить, если RestTemplate инжектируется
// import java.net.InetSocketAddress; // <-- Удалить, если RestTemplate инжектируется
import ru.cathub.telegabot.configuration.OpenRouterConfig;
import ru.cathub.telegabot.exception.OpenRouterException;
import ru.cathub.telegabot.model.TextRole;
import ru.cathub.telegabot.service.BotSettingService; // <-- Добавить импорт
import ru.cathub.telegabot.service.OpenRouterService;
import ru.cathub.telegabot.utils.Constants; // <-- Добавить импорт
import java.util.ArrayList;
import java.util.HashMap;
@@ -23,75 +28,144 @@ import java.util.Map;
@Slf4j
public class OpenRouterServiceImpl implements OpenRouterService {
private final OpenRouterConfig config;
private final RestTemplate restTemplate;
private final OpenRouterConfig config; // Оставляем для URL и Key
private final RestTemplate restTemplate; // <-- Инжектируем RestTemplate из бина
private final BotSettingService botSettingService; // <-- Инжектируем сервис настроек
// private RestTemplate createProxyRestTemplate() { // <-- Удалить этот метод
// SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// factory.setProxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("192.168.0.5", 1984)));
// return new RestTemplate(factory);
// }
@Override
public String getChatResponse(String userMessage, TextRole role) throws OpenRouterException {
List<Map<String, String>> messages = new ArrayList<>();
messages.add(Map.of("role", "system", "content", role.getPrompt()));
messages.add(Map.of("role", "user", "content", userMessage));
return sendChatRequest(messages);
// Передаем userMessage во второй аргумент для использования в sendChatRequest (например, для логгирования)
return sendChatRequest(messages, userMessage);
}
@Override
public String getChatResponseWithContext(String userMessage, List<Map<String, String>> context) throws OpenRouterException {
context.add(Map.of("role", "user", "content", userMessage));
return sendChatRequest(context);
// Создаем копию контекста, чтобы не модифицировать исходный список
List<Map<String, String>> messages = new ArrayList<>(context);
// Добавляем текущее сообщение пользователя в конец контекста, если его там еще нет
if (messages.isEmpty() || !messages.get(messages.size() - 1).get("role").equals("user")) {
messages.add(Map.of("role", "user", "content", userMessage));
} else {
// Если последнее сообщение уже от user, возможно, это повторный вызов, просто используем userMessage для логгирования
log.trace("Last message in context is already from user. Using provided userMessage for logging.");
}
return sendChatRequest(messages, userMessage);
}
@Override
public String getSimpleResponse(String message) throws OpenRouterException {
// Передаем message во второй аргумент для использования в sendChatRequest
return sendChatRequest(List.of(
Map.of("role", "user", "content", message)
));
), message);
}
private String sendChatRequest(List<Map<String, String>> messages) throws OpenRouterException {
// Добавляем второй аргумент для информации, которая может быть полезна для логгирования
private String sendChatRequest(List<Map<String, String>> messages, String originalUserMessage) throws OpenRouterException {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(config.getKey());
headers.add("HTTP-Referer", "https://github.com/your-username/telegabot");
headers.add("X-Title", "TelegaBot");
headers.setBearerAuth(config.getKey()); // Используем ключ из конфига
headers.setContentType(MediaType.APPLICATION_JSON); // Указываем тип контента
// Можно оставить старые заголовки или использовать новые, если нужно
headers.add("HTTP-Referer", "https://github.com/your-repo"); // Замените на ваш репозиторий
headers.add("X-Title", "TelegaBot Book Assistant"); // Замените на имя вашего бота
// Получаем имя модели из настроек бота
String currentModel = botSettingService.getSettingValueOrDefault(
Constants.AI_MODEL_SETTING_KEY,
Constants.DEFAULT_AI_MODEL // Значение по умолчанию
);
log.debug("Using AI model: {}", currentModel); // Логируем используемую модель
Map<String, Object> request = new HashMap<>();
request.put("model", config.getModel());
request.put("model", currentModel); // <-- Используем модель из настроек
request.put("messages", messages);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(request, headers);
try {
// Логгируем часть исходного сообщения пользователя
String messageSnippet = originalUserMessage != null ? originalUserMessage.substring(0, Math.min(originalUserMessage.length(), 100)) : "[null]";
log.info("Sending request to OpenRouter. Model: {}. Message snippet: '{}...'", currentModel, messageSnippet);
ResponseEntity<Map> response = restTemplate.postForEntity(
config.getUrl() + "/chat/completions",
new HttpEntity<>(request, headers),
config.getUrl() + "/chat/completions", // Используем URL из конфига
entity, // Используем созданный entity
Map.class
);
// Обработка ответа остается как была, но можно добавить больше логов
log.debug("Received response from OpenRouter. Status: {}", response.getStatusCode());
return extractResponse(response.getBody());
} catch (RestClientException e) {
log.error("OpenRouter API error: {}", e.getMessage());
throw new OpenRouterException("API request failed: " + e.getMessage());
log.error("OpenRouter API RestClientException: {}. URL: {}", e.getMessage(), config.getUrl(), e); // Добавляем stack trace в лог
// Можно добавить детали в сообщение об ошибке
throw new OpenRouterException("Ошибка сети при обращении к API OpenRouter: " + e.getMessage(), e);
} catch (Exception e) { // Ловим другие возможные ошибки
log.error("Unexpected error during OpenRouter API call: {}", e.getMessage(), e);
throw new OpenRouterException("Непредвиденная ошибка при вызове API OpenRouter.", e);
}
}
// Метод extractResponse остается без изменений, но с улучшенным логгированием
private String extractResponse(Map<String, Object> response) throws OpenRouterException {
try {
if (!response.containsKey("choices")) {
throw new OpenRouterException("Invalid API response format");
if (response == null) {
log.error("OpenRouter API response body is null.");
throw new OpenRouterException("API вернуло пустой ответ.");
}
if (response.containsKey("error")) {
Object errorObj = response.get("error");
String errorMessage = errorObj instanceof Map ? ((Map<?, ?>) errorObj).get("message").toString() : String.valueOf(errorObj);
log.error("OpenRouter API error in response: {}", errorMessage);
throw new OpenRouterException("API OpenRouter вернуло ошибку: " + errorMessage);
}
if (!response.containsKey("choices")) {
log.error("Invalid API response format: 'choices' key is missing. Response: {}", response);
throw new OpenRouterException("Неверный формат ответа API: отсутствует поле 'choices'.");
}
List<?> choices = (List<?>) response.get("choices");
if (choices.isEmpty()) {
throw new OpenRouterException("No choices in API response");
log.warn("OpenRouter API response has empty 'choices' list.");
throw new OpenRouterException("API не вернуло вариантов ответа ('choices' пуст).");
}
Map<?, ?> firstChoice = (Map<?, ?>) choices.get(0);
Map<?, ?> message = (Map<?, ?>) firstChoice.get("message");
if (message == null || !message.containsKey("content")) {
throw new OpenRouterException("Malformed message in API response");
if (firstChoice == null) {
log.error("First choice in API response is null. Choices: {}", choices);
throw new OpenRouterException("Неверный формат ответа API: первый вариант ответа пуст.");
}
return (String) message.get("content");
Map<?, ?> message = (Map<?, ?>) firstChoice.get("message");
if (message == null || !message.containsKey("content")) {
log.error("Malformed message in API response: 'message' or 'content' is missing. Choice: {}", firstChoice);
throw new OpenRouterException("Неверный формат сообщения в ответе API.");
}
Object contentObj = message.get("content");
if (contentObj == null) {
log.warn("Content in API response message is null. Message: {}", message);
// Возвращаем пустую строку или выбрасываем исключение, в зависимости от ожидаемого поведения
return "";
// throw new OpenRouterException("Содержимое ответа API пусто (null).");
}
String content = (String) contentObj;
log.info("Successfully extracted content from OpenRouter response. Length: {}", content.length());
return content;
} catch (ClassCastException e) {
throw new OpenRouterException("Type casting error in API response");
log.error("Type casting error while parsing OpenRouter API response: {}", e.getMessage(), e);
throw new OpenRouterException("Ошибка при обработке ответа API (неверные типы данных).", e);
}
}
}
@@ -2,77 +2,192 @@ package ru.cathub.telegabot.service.impl;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j; // Добавим логгер
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.cathub.telegabot.bot.KeyboardHelper;
import ru.cathub.telegabot.exception.ServiceException;
import ru.cathub.telegabot.model.BotUser;
import ru.cathub.telegabot.repository.UserRepository;
import ru.cathub.telegabot.service.BotSettingService; // <-- Добавить импорт
import ru.cathub.telegabot.service.ProfileService;
import ru.cathub.telegabot.service.TelegramClientService;
import ru.cathub.telegabot.utils.Constants;
import java.util.Optional;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import static ru.cathub.telegabot.utils.Constants.SET_BOOKS_GOAL;
import static ru.cathub.telegabot.bot.KeyboardHelper.getEditTargerBooksKeyboard;
import static ru.cathub.telegabot.utils.BookFormatUtils.formatBookLeftStats;
// Удаляем неиспользуемый импорт SET_BOOKS_GOAL
// import static ru.cathub.telegabot.utils.Constants.SET_BOOKS_GOAL;
import static ru.cathub.telegabot.utils.Constants.*; // Импортируем все константы
@Service
@RequiredArgsConstructor
@Slf4j // Добавляем логгер
public class ProfileServiceImpl implements ProfileService {
private final UserRepository userRepository;
private final TelegramClientService telegramClientService;
private final BotSettingService botSettingService; // <-- Добавить зависимость
@Override
public void enterProfileEditingMode(BotUser user) {
user.setWorkingMode(BotUser.WorkingMode.EDIT_PROFILE);
user.setWorkingMode(BotUser.WorkingMode.EDIT_PROFILE); // Режим выбора опции
userRepository.save(user);
boolean isAdmin = botSettingService.isAdmin(user); // <-- Проверить админа
log.debug("Entering profile edit mode for user {}. Is admin: {}", user.getId(), isAdmin);
telegramClientService.sendMessage(
user,
user,
"⌨️ Выберите параметр для редактирования:",
KeyboardHelper.getProfileMenuKeyboard()
// Передать флаг isAdmin в хелпер клавиатуры
KeyboardHelper.getProfileMenuKeyboard(isAdmin)
);
}
@Override
public void handleProfileUpdate(BotUser user, String input) throws ServiceException {
log.debug("Handling profile update for user {} in mode {}. Input: '{}'", user.getId(), user.getWorkingMode(), input);
try {
if (input.equals(SET_BOOKS_GOAL)) {
handleBooksGoalUpdate(user);
} else {
processFieldUpdate(user, input);
// Определяем, какое поле обновляем, по текущему режиму работы
switch (user.getWorkingMode()) {
case EDIT_PROFILE_BOOKS_GOAL:
processBooksGoalUpdate(user, input); // <-- Вызвать переименованный метод
break;
case EDIT_PROFILE_AI_MODEL:
processAiModelUpdate(user, input); // <-- Вызвать новый метод
break;
default:
// Если мы здесь, значит пользователь ввел текст, когда не должен был (например, в режиме EDIT_PROFILE)
log.warn("User {} provided unexpected input '{}' in mode {}", user.getId(), input, user.getWorkingMode());
boolean isAdmin = botSettingService.isAdmin(user);
telegramClientService.sendMessage(user, "Пожалуйста, выберите опцию из меню.", KeyboardHelper.getProfileMenuKeyboard(isAdmin));
// Не сбрасываем режим, даем пользователю выбрать опцию
return; // Выходим, чтобы не сбросить режим в finally
}
} finally {
resetEditingState(user);
// Сбрасываем режим только если обновление прошло успешно или была ошибка в обработке
if (user.getWorkingMode() != BotUser.WorkingMode.EDIT_PROFILE) { // Не сбрасываем, если просто показали меню
log.debug("Resetting editing state for user {}", user.getId());
resetEditingState(user);
} else {
log.debug("Not resetting state for user {} as they are still in EDIT_PROFILE mode", user.getId());
}
}
}
@Override
public void handleBooksGoalUpdate(BotUser user) {
user.setWorkingMode(BotUser.WorkingMode.EDIT_PROFILE);
// Переименован из handleBooksGoalUpdate
public void handleBooksGoalUpdateInitiation(BotUser user) {
user.setWorkingMode(BotUser.WorkingMode.EDIT_PROFILE_BOOKS_GOAL); // <-- Установить конкретный режим
userRepository.save(user);
log.info("User {} initiated books goal update.", user.getId());
telegramClientService.sendMessage(
user,
user,
"Текущая цель: " + (user.getBooksToRead() != null ? String.valueOf(user.getBooksToRead()) : "Не задано") + "\nВведите новое количество книг:"
);
}
private void processFieldUpdate(BotUser user, String input) throws ServiceException {
// <-- Новый метод -->
@Override
public void handleAiModelUpdateInitiation(BotUser user) throws ServiceException {
if (!botSettingService.isAdmin(user)) {
log.warn("Non-admin user {} attempted to initiate AI model update.", user.getId());
throw new ServiceException("У вас нет прав для изменения этой настройки.");
}
user.setWorkingMode(BotUser.WorkingMode.EDIT_PROFILE_AI_MODEL); // <-- Установить конкретный режим
userRepository.save(user);
log.info("Admin user {} initiated AI model update.", user.getId());
String currentModel = botSettingService.getSettingValueOrDefault(
Constants.AI_MODEL_SETTING_KEY,
Constants.DEFAULT_AI_MODEL
);
telegramClientService.sendMessageWithMarkdown( // Используем Markdown для форматирования `code`
user,
String.format(Constants.ENTER_NEW_AI_MODEL, currentModel)
);
}
@Override
@Transactional(readOnly = true) // Добавим readOnly = true
public void handleBooksGoalShow(BotUser user) {
// Используем user напрямую, если он актуален, или получаем свежую копию
BotUser freshUser = userRepository.findById(user.getId()).orElseThrow(() -> new RuntimeException("User not found"));
Integer targetOpt = freshUser.getBooksToRead(); // Цель может быть null
int target = targetOpt != null ? targetOpt : 0; // Если null, считаем целью 0
int read = freshUser.getBooks() != null ? freshUser.getBooks().size() : 0;
int left = target - read;
LocalDate today = LocalDate.now();
LocalDate endOfYear = LocalDate.of(today.getYear(), 12, 31);
int weeksLeft = Math.toIntExact(ChronoUnit.WEEKS.between(today, endOfYear));
var stats = formatBookLeftStats(weeksLeft, target, read, left);
telegramClientService.sendMessageWithMarkdown(user, stats, getEditTargerBooksKeyboard());
}
// Переименован из processFieldUpdate
private void processBooksGoalUpdate(BotUser user, String input) throws ServiceException {
try {
int goal = Integer.parseInt(input);
if (goal < 0) throw new NumberFormatException();
int goal = Integer.parseInt(input.trim()); // Обрезаем пробелы
if (goal < 0) {
log.warn("User {} entered invalid negative goal: {}", user.getId(), goal);
throw new NumberFormatException("Goal cannot be negative");
}
user.setBooksToRead(goal);
// userRepository.save(user); // Сохранение будет в resetEditingState
log.info("User {} updated books goal to {}", user.getId(), goal);
telegramClientService.sendMessage(
user,
Constants.PROFILE_UPDATED,
KeyboardHelper.getMainMenuKeyboard()
user,
Constants.PROFILE_UPDATED,
KeyboardHelper.getMainMenuKeyboard() // Возвращаем в главное меню после успешного обновления
);
} catch (NumberFormatException e) {
throw new ServiceException("❌ Ошибка! Введите целое положительное число");
log.warn("User {} entered invalid number format for goal: {}", user.getId(), input);
throw new ServiceException("❌ Ошибка! Введите целое положительное число.");
}
}
// <-- Новый метод -->
private void processAiModelUpdate(BotUser user, String input) throws ServiceException {
// Дополнительная проверка прав не нужна, т.к. она есть в handleAiModelUpdateInitiation
// и setSettingValue внутри себя тоже проверяет права
try {
String newModel = input.trim();
if (newModel.isEmpty()) {
log.warn("Admin user {} entered empty AI model name.", user.getId());
throw new ServiceException("❌ Имя модели не может быть пустым.");
}
botSettingService.setSettingValue(Constants.AI_MODEL_SETTING_KEY, newModel, user);
log.info("Admin user {} updated AI model to '{}'", user.getId(), newModel);
telegramClientService.sendMessage(
user,
Constants.AI_MODEL_UPDATED,
KeyboardHelper.getMainMenuKeyboard() // Возвращаем в главное меню
);
} catch (ServiceException e) {
// Ловим ошибку, если вдруг setSettingValue ее выбросит (например, проблемы с БД или правами)
log.error("ServiceException while admin {} updating AI model: {}", user.getId(), e.getMessage(), e);
throw new ServiceException("❌ Не удалось обновить модель ИИ: " + e.getMessage(), e);
} catch (Exception e) {
// Ловим другие возможные ошибки
log.error("Unexpected error while admin {} updating AI model: {}", user.getId(), e.getMessage(), e);
throw new ServiceException("❌ Произошла непредвиденная ошибка при обновлении модели ИИ.", e);
}
}
private void resetEditingState(BotUser user) {
user.setWorkingMode(BotUser.WorkingMode.NO_MODE);
user.setWorkingMode(BotUser.WorkingMode.NO_MODE); // <-- Установить NO_MODE
userRepository.save(user);
log.debug("User {} state reset to NO_MODE.", user.getId());
}
}
@@ -1,35 +1,53 @@
package ru.cathub.telegabot.service.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; // <-- Добавить логгер
import org.springframework.stereotype.Service;
import ru.cathub.telegabot.bot.KeyboardHelper;
import ru.cathub.telegabot.exception.ServiceException;
import ru.cathub.telegabot.model.BotUser;
import ru.cathub.telegabot.repository.UserRepository;
import ru.cathub.telegabot.service.BookManageService;
import ru.cathub.telegabot.service.ProfileService;
import ru.cathub.telegabot.service.TelegramBotService;
import ru.cathub.telegabot.service.TelegramClientService;
import ru.cathub.telegabot.service.*; // <-- Импортировать все сервисы
import static ru.cathub.telegabot.utils.Constants.*;
@Service
@RequiredArgsConstructor
@Slf4j // <-- Добавить аннотацию логгера
public class TelegramBotServiceImpl implements TelegramBotService {
private final TelegramClientService telegramClientService;
private final BookManageService bookManageService;
private final ProfileService profileService;
private final UserRepository userRepository;
private final BookAiServiceImpl bookAiServiceImpl;
private final BookAiService bookAiService; // <-- Используем интерфейс
private final BotSettingService botSettingService; // <-- Добавить зависимость
@Override
public void processInput(BotUser botUser, String text) throws ServiceException {
switch (text) {
case EDIT_PROFILE:
profileService.enterProfileEditingMode(botUser);
break;
// case "Отправить текст ИИ":
log.debug("Processing input '{}' for user {} in mode {}", text, botUser.getId(), botUser.getWorkingMode());
try { // Обернем весь метод в try-catch для ServiceException
switch (text) {
case EDIT_PROFILE:
profileService.enterProfileEditingMode(botUser);
break;
// Обработка нажатия кнопок из меню профиля
case SET_BOOKS_GOAL:
profileService.handleBooksGoalUpdateInitiation(botUser);
break;
case EDIT_AI_MODEL: // <-- Добавить обработку новой кнопки
try {
profileService.handleAiModelUpdateInitiation(botUser);
} catch (ServiceException e) {
// Отправляем сообщение об ошибке прав доступа пользователю
log.warn("User {} failed to initiate AI model update: {}", botUser.getId(), e.getMessage());
telegramClientService.sendMessage(botUser, e.getMessage());
// Возвращаем пользователя в главное меню, так как он не может редактировать
processStart(botUser);
}
break;
// case "Отправить текст ИИ":
// sendMessage(chatId, "Введите ваш текст:");
// break;
//
@@ -43,52 +61,128 @@ public class TelegramBotServiceImpl implements TelegramBotService {
case "Помощь":
telegramClientService.sendMessage(botUser, "Выберите действие из меню.");
break;
bookManageService.handleStartMessage(botUser);
break;
case BACK:
processStart(botUser);
break;
case EDIT_BOOKS:
bookManageService.processBookInput(botUser,text);
break;
case SET_BOOKS_GOAL_FROM_BOOKS:
profileService.handleBooksGoalUpdate(botUser);
break;
default:
switch (botUser.getWorkingMode()) {
case BOOK:
bookManageService.processBookInput(botUser,text);
break;
case EDIT_PROFILE:
profileService.handleProfileUpdate(botUser, text);
break;
case AI_FILTER,AI_SUGGEST:
bookAiServiceImpl.processAiRequest(botUser,text,botUser.getWorkingMode());
break;
}
case BACK:
log.debug("Processing BACK button for user {} in mode {}", botUser.getId(), botUser.getWorkingMode());
// Определяем, откуда был нажат BACK
if (botUser.getWorkingMode() == BotUser.WorkingMode.EDIT_PROFILE ||
botUser.getWorkingMode() == BotUser.WorkingMode.EDIT_PROFILE_AI_MODEL ||
botUser.getWorkingMode() == BotUser.WorkingMode.EDIT_PROFILE_BOOKS_GOAL) {
// Если были в редактировании профиля, возвращаемся в главное меню
log.debug("Back from profile edit, returning to main menu for user {}", botUser.getId());
processStart(botUser);
} else if (botUser.getWorkingMode() == BotUser.WorkingMode.BOOK ||
botUser.getWorkingMode() == BotUser.WorkingMode.AI_FILTER ||
botUser.getWorkingMode() == BotUser.WorkingMode.AI_SUGGEST) {
// Если были в режиме Книжника или AI, возвращаемся в меню Книжника
log.debug("Back from book/AI mode, returning to book menu for user {}", botUser.getId());
bookManageService.handleStartMessage(botUser);
}
else {
// По умолчанию возвращаемся в главное меню
log.debug("Back from unknown/NO_MODE, returning to main menu for user {}", botUser.getId());
processStart(botUser);
}
break;
case EDIT_BOOKS:
case ADD_BOOK: // Добавим обработку этих кнопок здесь, если они ведут в режим BOOK
case LIST_BOOKS:
case FILTER_BOOKS:
case RECOMMEND_BOOKS:
bookManageService.processBookInput(botUser, text);
break;
case SET_BOOKS_GOAL_FROM_BOOKS:
profileService.handleBooksGoalShow(botUser);
break;
default:
log.debug("Processing default text input for user {} in mode {}", botUser.getId(), botUser.getWorkingMode());
// Обработка ввода текста в зависимости от режима
switch (botUser.getWorkingMode()) {
case BOOK:
bookManageService.processBookInput(botUser, text);
break;
// Обработка ввода для конкретных полей профиля
case EDIT_PROFILE_BOOKS_GOAL:
case EDIT_PROFILE_AI_MODEL: // <-- Добавить обработку ввода для AI модели
profileService.handleProfileUpdate(botUser, text);
break;
case AI_FILTER:
case AI_SUGGEST:
bookAiService.processAiRequest(botUser, text, botUser.getWorkingMode());
break;
case EDIT_PROFILE:
// Пользователь ввел текст вместо нажатия кнопки в меню профиля
log.warn("User {} entered text '{}' while in EDIT_PROFILE mode", botUser.getId(), text);
boolean isAdmin = botSettingService.isAdmin(botUser);
telegramClientService.sendMessage(botUser, "Пожалуйста, выберите опцию из меню.", KeyboardHelper.getProfileMenuKeyboard(isAdmin));
break;
case NO_MODE:
default:
// Неожиданный ввод текста, когда не ожидается
log.warn("User {} entered unexpected text '{}' in mode {}", botUser.getId(), text, botUser.getWorkingMode());
telegramClientService.sendMessage(botUser, "Неизвестная команда или неверный режим. Используйте /start для возврата в главное меню.");
processStart(botUser); // Возвращаем в начало
break;
}
}
} catch (ServiceException e) {
log.error("ServiceException during processing input '{}' for user {}: {}", text, botUser.getId(), e.getMessage(), e);
telegramClientService.sendMessage(botUser, "Произошла ошибка: " + e.getMessage());
// Можно вернуть пользователя в главное меню при ошибке
processStart(botUser);
} catch (Exception e) { // Ловим остальные неожиданные ошибки
log.error("Unexpected exception during processing input '{}' for user {}: {}", text, botUser.getId(), e.getMessage(), e);
telegramClientService.sendMessage(botUser, "Произошла непредвиденная ошибка. Попробуйте позже.");
processStart(botUser);
}
}
@Override
public void processCommand(BotUser botUser, String text) {
switch (text) {
case START:
processStart(botUser);
break;
// Можно добавить админские команды здесь, если нужно
}
}
public void processStart(BotUser botUser){
telegramClientService.sendMessage(botUser, "Добро пожаловать! Выберите действие:", KeyboardHelper.getMainMenuKeyboard());
log.info("Processing /start for user {}", botUser.getId());
// Сбрасываем режим при старте
botUser.setWorkingMode(BotUser.WorkingMode.NO_MODE);
userRepository.save(botUser);
telegramClientService.sendMessage(botUser, "Добро пожаловать! Выберите действие:", KeyboardHelper.getMainMenuKeyboard());
}
@Override
public void processQuery(BotUser botUser, String callbackData)
{
if (callbackData.startsWith("BOOK_")) {
bookManageService.processBookQuery(botUser,callbackData);
log.debug("Processing query '{}' for user {}", callbackData, botUser.getId());
try { // Обернем в try-catch
if (callbackData.startsWith("BOOK_")) {
bookManageService.processBookQuery(botUser, callbackData);
} else if (callbackData.equals("EDIT_TARGET")) {
// Эта кнопка теперь инициирует редактирование цели
profileService.handleBooksGoalUpdateInitiation(botUser);
} else {
log.warn("Unknown callback query received: {} for user {}", callbackData, botUser.getId());
// Можно отправить сообщение пользователю или просто проигнорировать
telegramClientService.sendMessage(botUser, "Неизвестный запрос.");
}
// } catch (ServiceException e) {
// log.error("ServiceException during processing query '{}' for user {}: {}", callbackData, botUser.getId(), e.getMessage(), e);
// telegramClientService.sendMessage(botUser, "Ошибка обработки запроса: " + e.getMessage());
// processStart(botUser); // Возвращаем в главное меню при ошибке
} catch (Exception e) {
log.error("Unexpected exception during processing query '{}' for user {}: {}", callbackData, botUser.getId(), e.getMessage(), e);
telegramClientService.sendMessage(botUser, "Произошла непредвиденная ошибка при обработке запроса.");
processStart(botUser);
}
}
}
@@ -46,5 +46,12 @@ public class Constants {
public static final String AI_FILTER_RATING = "Отфильтруй книги по по рейтингу больше 8";
public static final String AI_SUGGEST_SIMILAR = "Предложи похожие книги";
public static final String AI_SUGGEST_GENRE = "Предложи еше книги в жанре фентези";
public static final String AI_SUGGEST_GENRE = "Предложи еше книги в жанре фэнтези";
// AI Settings (Admin)
public static final String EDIT_AI_MODEL = "Модель ИИ";
public static final String AI_MODEL_SETTING_KEY = "OPENROUTER_API_MODEL";
public static final String DEFAULT_AI_MODEL = "google/gemini-2.5-pro-exp-03-25:free"; // Значение по умолчанию
public static final String AI_MODEL_UPDATED = "Модель ИИ обновлена! \uD83E\uDD16";
public static final String ENTER_NEW_AI_MODEL = "Текущая модель: `%s`\nВведите новое имя модели OpenRouter:"; // %s будет заменено на текущую модель
}
+11 -2
View File
@@ -9,6 +9,7 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.properties.hibernate.default_schema=telegabot
spring.datasource.hikari.keepalive-time=300000
# Flyway Configuration
spring.flyway.enabled=true
@@ -29,7 +30,7 @@ logging.level.org.springframework.cache=INFO
# OpenRouter AI
openrouter.api.url=https://openrouter.ai/api/v1
openrouter.api.model=qwen/qwen-max
# openrouter.api.model=google/gemini-2.5-pro-exp-03-25:free # <-- Закомментировано или удалено
# Для безопасности лучше установить через переменную окружения:
@@ -42,13 +43,21 @@ spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
# OpenRouter AI
openrouter.api.key=${OPENROUTER_API_KEY}
# Proxy Configuration (optional)
proxy.host=${PROXY_HOST:}
proxy.port=${PROXY_PORT:0}
# JobRunr Configuration
org.jobrunr.job-scheduler.enabled=true
org.jobrunr.background-job-server.enabled=true
org.jobrunr.database.type=sql
org.jobrunr.dashboard.enabled=true
org.jobrunr.database.tablePrefix=telegabot.
org.jobrunr.database.tablePrefix=telegabot.
#logging.level.ru.cathub.telegabot=TRACE