diff --git a/build.gradle.kts b/build.gradle.kts index 7acef27..90d887a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,6 +56,8 @@ dependencies { implementation("com.google.code.gson:gson") implementation("io.micrometer:micrometer-registry-prometheus") + implementation("org.telegram:telegrambots:6.9.7.1") + implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1") diff --git a/src/main/kotlin/space/luminic/budgerapp/BudgerAppApplication.kt b/src/main/kotlin/space/luminic/budgerapp/BudgerAppApplication.kt index 1c0cfbf..8d187c7 100644 --- a/src/main/kotlin/space/luminic/budgerapp/BudgerAppApplication.kt +++ b/src/main/kotlin/space/luminic/budgerapp/BudgerAppApplication.kt @@ -2,17 +2,20 @@ package space.luminic.budgerapp import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication import org.springframework.cache.annotation.EnableCaching import org.springframework.data.mongodb.repository.config.EnableMongoRepositories import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.annotation.EnableScheduling -import java.util.TimeZone +import space.luminic.budgerapp.configs.TelegramBotProperties +import java.util.* @SpringBootApplication(scanBasePackages = ["space.luminic.budgerapp"]) @EnableCaching @EnableAsync @EnableScheduling +@EnableConfigurationProperties(TelegramBotProperties::class) @EnableMongoRepositories(basePackages = ["space.luminic.budgerapp.repos"]) class BudgerAppApplication diff --git a/src/main/kotlin/space/luminic/budgerapp/configs/TelegramBotConfig.kt b/src/main/kotlin/space/luminic/budgerapp/configs/TelegramBotConfig.kt new file mode 100644 index 0000000..cf9ec34 --- /dev/null +++ b/src/main/kotlin/space/luminic/budgerapp/configs/TelegramBotConfig.kt @@ -0,0 +1,25 @@ +package space.luminic.budgerapp.configs + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.telegram.telegrambots.meta.TelegramBotsApi +import org.telegram.telegrambots.updatesreceivers.DefaultBotSession +import space.luminic.budgerapp.services.BotService + +@Configuration +class TelegramBotConfig { + + @Bean + fun telegramBotsApi(myBot: BotService): TelegramBotsApi { + val botsApi = TelegramBotsApi(DefaultBotSession::class.java) + botsApi.registerBot(myBot) + return botsApi + } +} + +@ConfigurationProperties(prefix = "telegram.bot") +data class TelegramBotProperties( + val username: String, + val token: String +) diff --git a/src/main/kotlin/space/luminic/budgerapp/controllers/SpaceController.kt b/src/main/kotlin/space/luminic/budgerapp/controllers/SpaceController.kt index 8e67f10..245724e 100644 --- a/src/main/kotlin/space/luminic/budgerapp/controllers/SpaceController.kt +++ b/src/main/kotlin/space/luminic/budgerapp/controllers/SpaceController.kt @@ -10,10 +10,7 @@ import org.springframework.web.bind.annotation.* import space.luminic.budgerapp.controllers.BudgetController.LimitValue import space.luminic.budgerapp.controllers.dtos.BudgetCreationDTO import space.luminic.budgerapp.models.* -import space.luminic.budgerapp.services.CategoryService -import space.luminic.budgerapp.services.FinancialService -import space.luminic.budgerapp.services.RecurrentService -import space.luminic.budgerapp.services.SpaceService +import space.luminic.budgerapp.services.* import java.time.LocalDate @RestController @@ -22,7 +19,8 @@ class SpaceController( private val spaceService: SpaceService, private val financialService: FinancialService, private val categoryService: CategoryService, - private val recurrentService: RecurrentService + private val recurrentService: RecurrentService, + private val authService: AuthService ) { private val log = LoggerFactory.getLogger(SpaceController::class.java) @@ -57,13 +55,16 @@ class SpaceController( @DeleteMapping("/{spaceId}") suspend fun deleteSpace(@PathVariable spaceId: String) { - return spaceService.deleteSpace(spaceService.isValidRequest(spaceId)) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) + return spaceService.deleteSpace(space) } @PostMapping("/{spaceId}/invite") suspend fun inviteSpace(@PathVariable spaceId: String): SpaceInvite { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return spaceService.createInviteSpace(spaceId) } @@ -74,13 +75,15 @@ class SpaceController( @DeleteMapping("/{spaceId}/leave") suspend fun leaveSpace(@PathVariable spaceId: String) { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return spaceService.leaveSpace(spaceId) } @DeleteMapping("/{spaceId}/members/kick/{username}") suspend fun kickMembers(@PathVariable spaceId: String, @PathVariable username: String) { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return spaceService.kickMember(spaceId, username) } @@ -90,7 +93,8 @@ class SpaceController( // @GetMapping("/{spaceId}/budgets") suspend fun getBudgets(@PathVariable spaceId: String): List { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return financialService.getBudgets(spaceId).awaitSingleOrNull().orEmpty() } @@ -98,7 +102,8 @@ class SpaceController( @GetMapping("/{spaceId}/budgets/{id}") suspend fun getBudget(@PathVariable spaceId: String, @PathVariable id: String): BudgetDTO? { log.info("Getting budget for spaceId=$spaceId, id=$id") - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return financialService.getBudget(spaceId, id) } @@ -107,8 +112,10 @@ class SpaceController( @PathVariable spaceId: String, @RequestBody budgetCreationDTO: BudgetCreationDTO, ): Budget? { + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return financialService.createBudget( - spaceService.isValidRequest(spaceId), + space, budgetCreationDTO.budget, budgetCreationDTO.createRecurrent ) @@ -116,7 +123,8 @@ class SpaceController( @DeleteMapping("/{spaceId}/budgets/{id}") suspend fun deleteBudget(@PathVariable spaceId: String, @PathVariable id: String) { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) financialService.deleteBudget(spaceId, id) } @@ -128,7 +136,8 @@ class SpaceController( @PathVariable catId: String, @RequestBody limit: LimitValue, ): BudgetCategory { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return financialService.setCategoryLimit(spaceId, budgetId, catId, limit.limit) } @@ -174,7 +183,8 @@ class SpaceController( @PostMapping("/{spaceId}/transactions") suspend fun createTransaction(@PathVariable spaceId: String, @RequestBody transaction: Transaction): Transaction { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return financialService.createTransaction(space, transaction) @@ -184,14 +194,16 @@ class SpaceController( suspend fun editTransaction( @PathVariable spaceId: String, @PathVariable id: String, @RequestBody transaction: Transaction ): Transaction { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) transaction.space = space - return financialService.editTransaction(transaction) + return financialService.editTransaction(transaction, user) } @DeleteMapping("/{spaceId}/transactions/{id}") suspend fun deleteTransaction(@PathVariable spaceId: String, @PathVariable id: String) { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) val transaction = financialService.getTransactionById(id) financialService.deleteTransaction(transaction) } @@ -206,7 +218,8 @@ class SpaceController( @RequestParam("sort") sortBy: String = "name", @RequestParam("direction") direction: String = "ASC" ): List { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return categoryService.getCategories(spaceId, type, sortBy, direction).awaitSingleOrNull().orEmpty() } @@ -219,7 +232,8 @@ class SpaceController( suspend fun createCategory( @PathVariable spaceId: String, @RequestBody category: Category ): Category { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return financialService.createCategory(space, category).awaitSingle() } @@ -229,33 +243,38 @@ class SpaceController( @RequestBody category: Category, @PathVariable spaceId: String ): Category { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return categoryService.editCategory(space, category) } @DeleteMapping("/{spaceId}/categories/{categoryId}") suspend fun deleteCategory(@PathVariable categoryId: String, @PathVariable spaceId: String) { - val space = spaceService.isValidRequest(spaceId) - categoryService.deleteCategory(space, categoryId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) + categoryService.deleteCategory(space, categoryId, user) } @GetMapping("/{spaceId}/categories/tags") suspend fun getTags(@PathVariable spaceId: String): List { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return spaceService.getTags(space) } @PostMapping("/{spaceId}/categories/tags") suspend fun createTags(@PathVariable spaceId: String, @RequestBody tag: Tag): Tag { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return spaceService.createTag(space, tag) } @DeleteMapping("/{spaceId}/categories/tags/{tagId}") suspend fun deleteTags(@PathVariable spaceId: String, @PathVariable tagId: String) { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return spaceService.deleteTag(space, tagId) } @@ -271,7 +290,8 @@ class SpaceController( @GetMapping("/{spaceId}/recurrents") suspend fun getRecurrents(@PathVariable spaceId: String): List { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return recurrentService.getRecurrents(spaceId) } @@ -279,13 +299,15 @@ class SpaceController( @GetMapping("/{spaceId}/recurrents/{id}") suspend fun getRecurrent(@PathVariable spaceId: String, @PathVariable id: String): Recurrent { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return recurrentService.getRecurrentById(space, id).awaitSingle() } @PostMapping("/{spaceId}/recurrent") suspend fun createRecurrent(@PathVariable spaceId: String, @RequestBody recurrent: Recurrent): Recurrent { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return recurrentService.createRecurrent(space, recurrent).awaitSingle() } @@ -295,14 +317,16 @@ class SpaceController( @PathVariable id: String, @RequestBody recurrent: Recurrent ): Recurrent { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return recurrentService.editRecurrent(recurrent).awaitSingle() } @DeleteMapping("/{spaceId}/recurrent/{id}") suspend fun deleteRecurrent(@PathVariable spaceId: String, @PathVariable id: String) { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) recurrentService.deleteRecurrent(id).awaitSingle() } diff --git a/src/main/kotlin/space/luminic/budgerapp/controllers/SubscriptionController.kt b/src/main/kotlin/space/luminic/budgerapp/controllers/SubscriptionController.kt index 69f1158..4bef911 100644 --- a/src/main/kotlin/space/luminic/budgerapp/controllers/SubscriptionController.kt +++ b/src/main/kotlin/space/luminic/budgerapp/controllers/SubscriptionController.kt @@ -35,7 +35,7 @@ class SubscriptionController( } @PostMapping("/notifyAll") - fun notifyAll(@RequestBody payload: PushMessage): ResponseEntity { + suspend fun notifyAll(@RequestBody payload: PushMessage): ResponseEntity { return try { ResponseEntity.ok(subscriptionService.sendToAll(payload)) } catch (e: Exception) { diff --git a/src/main/kotlin/space/luminic/budgerapp/controllers/WishListController.kt b/src/main/kotlin/space/luminic/budgerapp/controllers/WishListController.kt index 693ccca..89574fa 100644 --- a/src/main/kotlin/space/luminic/budgerapp/controllers/WishListController.kt +++ b/src/main/kotlin/space/luminic/budgerapp/controllers/WishListController.kt @@ -3,6 +3,7 @@ package space.luminic.budgerapp.controllers import org.springframework.web.bind.annotation.* import space.luminic.budgerapp.models.WishList import space.luminic.budgerapp.models.WishListItem +import space.luminic.budgerapp.services.AuthService import space.luminic.budgerapp.services.SpaceService import space.luminic.budgerapp.services.WishListService @@ -11,24 +12,28 @@ import space.luminic.budgerapp.services.WishListService @RequestMapping("/spaces/{spaceId}/wishlists") class WishListController( private val wishListService: WishListService, - private val spaceService: SpaceService + private val spaceService: SpaceService, + private val authService: AuthService ) { @GetMapping suspend fun findWishList(@PathVariable spaceId: String): List { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return wishListService.findWishLists(spaceId) } @GetMapping("/{wishListId}") suspend fun getWishList(@PathVariable spaceId: String, @PathVariable wishListId: String): WishList { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return wishListService.getList(wishListId) } @PostMapping suspend fun createWishList(@PathVariable spaceId: String, @RequestBody wishList: WishList): WishList { - val space = spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + val space = spaceService.isValidRequest(spaceId, user) return wishListService.createWishList(space, wishList) } @@ -38,7 +43,8 @@ class WishListController( @PathVariable wishListId: String, @RequestBody wishList: WishList ): WishList { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return wishListService.updateWishListInfo(wishList) } @@ -49,7 +55,8 @@ class WishListController( @PathVariable itemId: String, @RequestBody wishListItem: WishListItem ): WishList { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return wishListService.updateWishListItemInfo(wishListId, wishListItem) } @@ -59,7 +66,8 @@ class WishListController( @PathVariable wishListId: String, @RequestBody wishListItem: WishListItem ): WishList { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return wishListService.addItemToWishList(wishListId, wishListItem) } @@ -69,13 +77,15 @@ class WishListController( @PathVariable wishListId: String, @PathVariable wishListItemId: String ): WishList { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return wishListService.removeItemFromWishList(wishListId, wishListItemId) } @DeleteMapping("/{wishListId}") suspend fun deleteWishList(@PathVariable spaceId: String, @PathVariable wishListId: String) { - spaceService.isValidRequest(spaceId) + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) wishListService.deleteWishList(wishListId) } @@ -83,8 +93,9 @@ class WishListController( suspend fun cancelReserve( @PathVariable spaceId: String, @PathVariable wishListId: String, @PathVariable wishlistItemId: String - ) : WishList { - spaceService.isValidRequest(spaceId) + ): WishList { + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) return wishListService.cancelReserve(wishListId, wishlistItemId) } } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/budgerapp/models/BotStates.kt b/src/main/kotlin/space/luminic/budgerapp/models/BotStates.kt new file mode 100644 index 0000000..77ab2da --- /dev/null +++ b/src/main/kotlin/space/luminic/budgerapp/models/BotStates.kt @@ -0,0 +1,22 @@ +package space.luminic.budgerapp.models + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.DBRef +import org.springframework.data.mongodb.core.mapping.Document + +enum class BotStates { + WAIT_CATEGORY +} + +@Document("bot-user-states") +data class BotUserState( + @Id val id: String? = null, + @DBRef val user: User, + var data: MutableList = mutableListOf(), +) + +data class ChatData ( + val chatId: String, + var state: BotStates, + var data: MutableMap = mutableMapOf(), +) \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/budgerapp/models/Exceptions.kt b/src/main/kotlin/space/luminic/budgerapp/models/Exceptions.kt index 0b0c4bf..3985fe8 100644 --- a/src/main/kotlin/space/luminic/budgerapp/models/Exceptions.kt +++ b/src/main/kotlin/space/luminic/budgerapp/models/Exceptions.kt @@ -1,4 +1,5 @@ package space.luminic.budgerapp.models -open class NotFoundException(message: String) : Exception(message) \ No newline at end of file +open class NotFoundException(message: String) : Exception(message) +open class TelegramBotException(message: String, val chatId: Long) : Exception(message) \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/budgerapp/repos/BotStatesRepo.kt b/src/main/kotlin/space/luminic/budgerapp/repos/BotStatesRepo.kt new file mode 100644 index 0000000..ab7b1a0 --- /dev/null +++ b/src/main/kotlin/space/luminic/budgerapp/repos/BotStatesRepo.kt @@ -0,0 +1,7 @@ +package space.luminic.budgerapp.repos + +import org.springframework.data.mongodb.repository.ReactiveMongoRepository +import space.luminic.budgerapp.models.BotUserState + +interface BotStatesRepo: ReactiveMongoRepository { +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/budgerapp/repos/SubscriptionRepo.kt b/src/main/kotlin/space/luminic/budgerapp/repos/SubscriptionRepo.kt index a0d2bea..28d64d1 100644 --- a/src/main/kotlin/space/luminic/budgerapp/repos/SubscriptionRepo.kt +++ b/src/main/kotlin/space/luminic/budgerapp/repos/SubscriptionRepo.kt @@ -1,10 +1,21 @@ package space.luminic.budgerapp.repos +import org.bson.types.ObjectId +import org.springframework.data.mongodb.repository.Query import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux import space.luminic.budgerapp.models.Subscription @Repository interface SubscriptionRepo : ReactiveMongoRepository { + + @Query("{ \$and: [ " + + "{ 'user': { '\$ref': 'users', '\$id': ?0 } }, " + + "{ 'isActive': true } " + + "]}") + fun findByUserIdAndIsActive(userId: ObjectId): Flux + + } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/budgerapp/services/AuthService.kt b/src/main/kotlin/space/luminic/budgerapp/services/AuthService.kt index d778e2e..1d79d22 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/AuthService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/AuthService.kt @@ -4,10 +4,10 @@ import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import org.springframework.cache.annotation.Cacheable +import org.springframework.security.core.context.ReactiveSecurityContextHolder import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.stereotype.Service -import reactor.core.publisher.Mono import space.luminic.budgerapp.configs.AuthException import space.luminic.budgerapp.models.TokenStatus import space.luminic.budgerapp.models.User @@ -28,6 +28,16 @@ class AuthService( ) { private val passwordEncoder = BCryptPasswordEncoder() + suspend fun getSecurityUser(): User { + val securityContextHolder = ReactiveSecurityContextHolder.getContext().awaitSingleOrNull() + ?: throw AuthException("Authentication failed") + val authentication = securityContextHolder.authentication + + val username = authentication.name + // Получаем пользователя по имени + return userService.getByUsername(username) + } + suspend fun login(username: String, password: String): String { val user = userRepository.findByUsername(username).awaitFirstOrNull() ?: throw UsernameNotFoundException("Пользователь не найден") diff --git a/src/main/kotlin/space/luminic/budgerapp/services/BotService.kt b/src/main/kotlin/space/luminic/budgerapp/services/BotService.kt new file mode 100644 index 0000000..f0234af --- /dev/null +++ b/src/main/kotlin/space/luminic/budgerapp/services/BotService.kt @@ -0,0 +1,308 @@ +package space.luminic.budgerapp.services + +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import kotlinx.coroutines.runBlocking +import org.bson.Document +import org.bson.types.ObjectId +import org.slf4j.LoggerFactory +import org.springframework.data.mongodb.core.ReactiveMongoTemplate +import org.springframework.data.mongodb.core.aggregation.Aggregation.* + +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.stereotype.Service +import org.telegram.telegrambots.bots.TelegramLongPollingBot +import org.telegram.telegrambots.meta.api.methods.send.SendMessage +import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage +import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageText +import org.telegram.telegrambots.meta.api.objects.Update +import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup +import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton +import org.telegram.telegrambots.meta.exceptions.TelegramApiException +import space.luminic.budgerapp.configs.TelegramBotConfig +import space.luminic.budgerapp.configs.TelegramBotProperties +import space.luminic.budgerapp.models.* +import space.luminic.budgerapp.repos.BotStatesRepo +import java.time.LocalDate +import java.time.LocalDateTime + +@Service +class BotService( + private val telegramBotProperties: TelegramBotProperties, + private val botStatesRepo: BotStatesRepo, + private val reactiveMongoTemplate: ReactiveMongoTemplate, + private val userService: UserService, + private val categoriesService: CategoryService, + private val financialService: FinancialService, + private val spaceService: SpaceService, +) : TelegramLongPollingBot(telegramBotProperties.token) { + private val logger = LoggerFactory.getLogger(javaClass) + + override fun getBotUsername(): String { + return telegramBotProperties.username + } + + override fun onUpdateReceived(update: Update) = runBlocking { + logger.info("Received message $update") + try { + if (update.hasCallbackQuery()) { + processCallback(update) + } else if (update.hasMessage()) { + if (update.message.hasText()) { + processMessage(update) + } else if (update.message.hasPhoto()) { + processPhoto(update) + } else if (update.message.hasVideo()) { + processVideo(update) + } + } + } catch (e: TelegramBotException) { + e.printStackTrace() + logger.error(e.message) + sendMessage(e.chatId.toString(), "${e.message}") + } + } + + private suspend fun processCallback(update: Update) { + val chatId = update.callbackQuery.message.chatId.toString() + val tgUserId = update.callbackQuery.from.id + val user = userService.getUserByTelegramId(tgUserId) ?: throw TelegramBotException( + "User ${update.callbackQuery.from.userName} not found", + chatId = update.callbackQuery.message.chatId + ) + val state = getState(user.id!!) + if (state != null) { + when (state.data.first { it.chatId == chatId }.state) { + BotStates.WAIT_CATEGORY -> + if (update.callbackQuery.data.startsWith("category_")) { + confirmTransaction( + chatId, + user = userService.getUserByTelegramId(tgUserId)!!, + update + ) + } else if (update.callbackQuery.data == "cancel") { + finishState(chatId, user) + val deleteMsg = DeleteMessage(chatId, update.callbackQuery.message.messageId) + execute(deleteMsg) + sendMessage(chatId, "Введите сумму и комментарий когда будете готовы.") + } + } + } + } + + private suspend fun processMessage(update: Update) { + val user = userService.getUserByTelegramId(update.message.from.id) ?: throw TelegramBotException( + "Мы не знакомы", + chatId = update.message.chatId, + ) + getState(user.id!!)?.data?.find { it.chatId == update.message.chatId.toString() }?.let { + if (it.state == BotStates.WAIT_CATEGORY) { + throw TelegramBotException( + "Уже есть открытый выбор категории", + update.message.chatId + ) + } + } + newExpense( + update.message.chatId.toString(), + user = user, + text = update.message.text, + ) + } + + private fun processPhoto(update: Update) { + + } + + private fun processVideo(update: Update) { + + } + + private fun sendMessage(chatId: String, text: String) { + val message = SendMessage(chatId, text) + try { + execute(message) + } catch (e: TelegramApiException) { + e.printStackTrace() + } + } + + private suspend fun newExpense(chatId: String, user: User, text: String) { + + val splitText = text.split(" ") + if (splitText.size < 2) { + try { + throw TelegramBotException("Сумма или комментарий не введены", chatId.toLong()) + + } catch (e: TelegramApiException) { + e.printStackTrace() + } + } else { + val sum = try { + splitText[0].toInt() + } catch (e: NumberFormatException) { + throw TelegramBotException("Кажется первый параметр не цифра", chatId.toLong()) + } + val textWOSum = splitText.drop(1) + var comment = "" + textWOSum.map { word -> + comment += "$word " + } + + val categories = + categoriesService.getCategories( + "67af3c0f652da946a7dd9931", + "EXPENSE", + sortBy = "name", + direction = "ASC" + ) + .awaitSingle() + val keyboard = InlineKeyboardMarkup() + val buttonLines = mutableListOf>() + categories.map { category -> + val btn = InlineKeyboardButton.builder().text("${category.icon} ${category.name}") + .callbackData("category_${category.id}").build() + + if (category.name.length >= 15) { + // Если текст длинный, создаём отдельную строку для кнопки + buttonLines.add(mutableListOf(btn)) + } else { + var isAdded = false + + // Пытаемся добавить кнопку в существующую строку + for (line in buttonLines) { + if (line.size < 2 && (line.isEmpty() || line[0].text.length < 14)) { + line.add(btn) + isAdded = true + break + } + } + + // Если не нашли подходящую строку, создаём новую + if (!isAdded) { + buttonLines.add(mutableListOf(btn)) + } else { + + } + } + } + val backButton = InlineKeyboardButton.builder().text("Отмена").callbackData("cancel").build() + + buttonLines.add(mutableListOf(backButton)) + keyboard.keyboard = buttonLines + + val message = SendMessage() + message.chatId = chatId + val msg = "Выберите категорию" + message.text = msg + message.replyMarkup = keyboard + val userState = BotUserState(user = user) + val chatData = + userState.data.find { it.chatId == chatId } ?: ChatData(chatId, state = BotStates.WAIT_CATEGORY) + chatData.data["sum"] = sum.toString() + chatData.data["comment"] = comment + userState.data.add(chatData) + try { + execute(message) + setState(userState) + } catch (e: TelegramApiException) { + e.printStackTrace() + } + } + } + + private suspend fun confirmTransaction(chatId: String, user: User, update: Update) { + val state = getState(user.id!!) + if (state == null) { + sendMessage(chatId, "Не можем найти информацию о сумме и комментарии") + return + } + val stateData = state.data.find { it.chatId == chatId } + if (stateData == null) { + sendMessage(chatId, "Не можем найти информацию о сумме и комментарии") + return + } + + val category = categoriesService.getCategories( + "67af3c0f652da946a7dd9931", + "EXPENSE", + sortBy = "name", + direction = "ASC" + ) + .awaitSingle() + .first { it.id == update.callbackQuery.data.split("_")[1] } + val space = spaceService.getSpace("67af3c0f652da946a7dd9931") + val instantType = financialService.getTransactionTypes().first { it.code == "INSTANT" } + val transaction = financialService.createTransaction( + space, + transaction = Transaction( + space = space, + type = instantType, + user = user, + category = category, + comment = stateData.data["comment"]!!.trim(), + date = LocalDate.now(), + amount = stateData.data["sum"]!!.toDouble(), + isDone = true, + parentId = null, + createdAt = LocalDateTime.now() + ), + user = user + ) + val editMsg = EditMessageText() + editMsg.chatId = chatId + editMsg.messageId = update.callbackQuery.message.messageId + editMsg.text = "Успешно создали транзакцию c id ${transaction.id}" + try { + execute(editMsg) +// execute(msg) + finishState(chatId, user) + } catch (e: TelegramApiException) { + e.printStackTrace() + logger.error(e.message) + } + } + + + private suspend fun getState(userId: String): BotUserState? { + val lookup = lookup("users", "user.\$id", "_id", "userDetails") + val unwind = unwind("userDetails") + val match = match(Criteria.where("userDetails._id").`is`(ObjectId(userId))) + + val aggregation = newAggregation(lookup, unwind, match) + return reactiveMongoTemplate.aggregate(aggregation, "bot-user-states", Document::class.java) + .next() + .map { doc -> + val dataList = doc.getList("data", Document::class.java) + BotUserState( + id = doc.getObjectId("_id").toString(), + user = User(doc.get("userDetails", Document::class.java).getObjectId("_id").toString()), + data = dataList.map { + val data = it.get("data", Document::class.java) + ChatData( + chatId = it.getString("chatId"), + state = BotStates.valueOf(it.getString("state")), + data = (data.toMap().mapValues { it.value.toString() }.toMutableMap()) + ) + }.toMutableList(), + ) + } + .awaitSingleOrNull() + } + + private suspend fun setState(userState: BotUserState): BotUserState { + val stateToSave = userState.user.id?.let { userId -> + getState(userId)?.copy(data = userState.data) + ?: BotUserState(user = userState.user, data = userState.data) + } ?: BotUserState(user = userState.user, data = userState.data) + + return botStatesRepo.save(stateToSave).awaitSingle() + } + + private suspend fun finishState(chatId: String, user: User) { + val state = getState(user.id!!) + state?.data?.removeIf { it.chatId == chatId } + state?.let { botStatesRepo.save(state).awaitSingle() } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/budgerapp/services/CategoryService.kt b/src/main/kotlin/space/luminic/budgerapp/services/CategoryService.kt index e1a2f5e..5fa98de 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/CategoryService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/CategoryService.kt @@ -17,10 +17,7 @@ import org.springframework.data.mongodb.core.query.isEqualTo import org.springframework.stereotype.Service import reactor.core.publisher.Mono import space.luminic.budgerapp.mappers.CategoryMapper -import space.luminic.budgerapp.models.Category -import space.luminic.budgerapp.models.CategoryType -import space.luminic.budgerapp.models.NotFoundException -import space.luminic.budgerapp.models.Space +import space.luminic.budgerapp.models.* import space.luminic.budgerapp.repos.BudgetRepo import space.luminic.budgerapp.repos.CategoryRepo @@ -133,7 +130,7 @@ class CategoryService( return categoryRepo.save(category).awaitSingle() // Сохраняем категорию, если тип не изменился } - suspend fun deleteCategory(space: Space, categoryId: String) { + suspend fun deleteCategory(space: Space, categoryId: String, author: User) { findCategory(space, categoryId) val transactions = financialService.getTransactions(space.id!!, categoryId = categoryId).awaitSingle() if (transactions.isNotEmpty()) { @@ -154,7 +151,7 @@ class CategoryService( transactions.map { transaction -> transaction.category = otherCategory - financialService.editTransaction(transaction) + financialService.editTransaction(transaction, author) } } val budgets = financialService.findProjectedBudgets( diff --git a/src/main/kotlin/space/luminic/budgerapp/services/FinancialService.kt b/src/main/kotlin/space/luminic/budgerapp/services/FinancialService.kt index d0fd3c2..8046f99 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/FinancialService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/FinancialService.kt @@ -1,11 +1,8 @@ package space.luminic.budgerapp.services -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.* import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitSingle @@ -35,6 +32,7 @@ import space.luminic.budgerapp.repos.CategoryRepo import space.luminic.budgerapp.repos.TransactionRepo import space.luminic.budgerapp.repos.WarnRepo import java.time.* +import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAdjusters import java.util.* @@ -48,7 +46,8 @@ class FinancialService( val reactiveMongoTemplate: ReactiveMongoTemplate, private val categoryRepo: CategoryRepo, val transactionsMapper: TransactionsMapper, - val budgetMapper: BudgetMapper + val budgetMapper: BudgetMapper, + private val subscriptionService: SubscriptionService ) { private val logger = LoggerFactory.getLogger(FinancialService::class.java) @@ -846,25 +845,45 @@ class FinancialService( } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - suspend fun createTransaction(space: Space, transaction: Transaction): Transaction { - val securityContextHolder = ReactiveSecurityContextHolder.getContext().awaitSingle() - val user = userService.getByUserNameWoPass(securityContextHolder.authentication.name) - if (space.users.none { it.id.toString() == user.id }) { + suspend fun createTransaction(space: Space, transaction: Transaction, user: User? = null): Transaction { + val author = user + ?: userService.getByUserNameWoPass( + ReactiveSecurityContextHolder.getContext().awaitSingle().authentication.name + ) + if (space.users.none { it.id.toString() == author.id }) { throw IllegalArgumentException("User does not have access to this Space") } // Привязываем space и user к транзакции - transaction.user = user + transaction.user = author transaction.space = space val savedTransaction = transactionsRepo.save(transaction).awaitSingle() + updateBudgetOnCreate(savedTransaction) + scope.launch { + val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + val transactionType = if (transaction.type.code == "INSTANT") "текущую" else "плановую" + + subscriptionService.sendToSpaceOwner( + space.owner!!.id!!, PushMessage( + title = "Новая транзакция в пространстве ${space.name}!", + body = "Пользователь ${author.username} создал $transactionType транзакцию на сумму ${transaction.amount.toInt()} с комментарием ${transaction.comment} с датой ${ + dateFormatter.format( + transaction.date + ) + }", + url = "https://luminic.space/" + ) + ) + } return savedTransaction } @CacheEvict(cacheNames = ["transactions", "budgets"], allEntries = true) - suspend fun editTransaction(transaction: Transaction): Transaction { + suspend fun editTransaction(transaction: Transaction, author: User): Transaction { val oldStateOfTransaction = getTransactionById(transaction.id!!) val changed = compareSumDateDoneIsChanged(oldStateOfTransaction, transaction) if (!changed) { @@ -878,8 +897,57 @@ class FinancialService( transaction ) } + val space = transaction.space val savedTransaction = transactionsRepo.save(transaction).awaitSingle() updateBudgetOnEdit(oldStateOfTransaction, savedTransaction, amountDifference) + scope.launch { + var whatChanged = "nothing" + val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + val transactionType = if (transaction.type.code == "INSTANT") "текущую" else "плановую" + + val sb = StringBuilder() + if (oldStateOfTransaction.amount != transaction.amount) { + sb.append("${oldStateOfTransaction.amount} → ${transaction.amount}\n") + whatChanged = "main" + } + if (oldStateOfTransaction.comment != transaction.comment) { + sb.append("${oldStateOfTransaction.comment} → ${transaction.comment}\n") + whatChanged = "main" + + } + if (oldStateOfTransaction.date != transaction.date) { + sb.append("${dateFormatter.format(oldStateOfTransaction.date)} → ${dateFormatter.format(transaction.date)}\n") + whatChanged = "main" + } + if (!oldStateOfTransaction.isDone && transaction.isDone) { + whatChanged = "done_true" + } + if (oldStateOfTransaction.isDone && !transaction.isDone) { + whatChanged = "done_false" + } + + val body: String = when (whatChanged) { + "main" -> { + "Пользователь ${author.username} изменил $transactionType транзакцию:\n$sb" + } + "done_true" -> { + "Пользователь ${author.username} выполнил ${transaction.comment} с суммой ${transaction.amount.toInt()}" + } + "done_false" -> { + "Пользователь ${author.username} отменил выполнение ${transaction.comment} с суммой ${transaction.amount.toInt()}" + } + else -> "Изменения не обнаружены, но что то точно изменилось" + } + + subscriptionService.sendToSpaceOwner( + space?.owner!!.id!!, PushMessage( + title = "Новое действие в пространстве ${space.name}!", + body = body, + url = "https://luminic.space/" + ) + ) + } + return savedTransaction } diff --git a/src/main/kotlin/space/luminic/budgerapp/services/SpaceService.kt b/src/main/kotlin/space/luminic/budgerapp/services/SpaceService.kt index 8925200..cc24835 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/SpaceService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/SpaceService.kt @@ -37,14 +37,9 @@ class SpaceService( private val tagRepo: TagRepo ) { - suspend fun isValidRequest(spaceId: String): Space { - val securityContextHolder = ReactiveSecurityContextHolder.getContext().awaitSingleOrNull() - ?: throw AuthException("Authentication failed") - val authentication = securityContextHolder.authentication - val username = authentication.name - // Получаем пользователя по имени - val user = userService.getByUsername(username) + + suspend fun isValidRequest(spaceId: String, user: User): Space { val space = getSpace(spaceId) // Проверяем доступ пользователя к пространству diff --git a/src/main/kotlin/space/luminic/budgerapp/services/SubscriptionService.kt b/src/main/kotlin/space/luminic/budgerapp/services/SubscriptionService.kt index ee5aa07..9870513 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/SubscriptionService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/SubscriptionService.kt @@ -3,13 +3,16 @@ package space.luminic.budgerapp.services import com.interaso.webpush.VapidKeys import com.interaso.webpush.WebPushService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.awaitSingle import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.bson.types.ObjectId import org.slf4j.LoggerFactory import org.springframework.dao.DuplicateKeyException import org.springframework.stereotype.Service -import reactor.core.publisher.Mono import space.luminic.budgerapp.models.PushMessage import space.luminic.budgerapp.models.Subscription import space.luminic.budgerapp.models.SubscriptionDTO @@ -36,38 +39,50 @@ class SubscriptionService(private val subscriptionRepo: SubscriptionRepo) { vapidKeys = VapidKeys.fromUncompressedBytes(VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY) ) - fun sendNotification(endpoint: String, p256dh: String, auth: String, payload: PushMessage): Mono { - return Mono.fromRunnable { + + suspend fun sendToSpaceOwner(ownerId: String, message: PushMessage) = coroutineScope { + val ownerTokens = subscriptionRepo.findByUserIdAndIsActive(ObjectId(ownerId)).collectList().awaitSingle() + + ownerTokens.forEach { token -> + launch(Dispatchers.IO) { // Теперь мы точно в корутин скоупе + try { + sendNotification(token.endpoint, token.p256dh, token.auth, message) + } catch (e: Exception) { + logger.error("Ошибка при отправке уведомления: ${e.message}", e) + } + } + } + } + + + suspend fun sendNotification(endpoint: String, p256dh: String, auth: String, payload: PushMessage) { + try { pushService.send( payload = Json.encodeToString(payload), endpoint = endpoint, p256dh = p256dh, auth = auth ) + logger.info("Уведомление успешно отправлено на endpoint: $endpoint") + + } catch (e: Exception) { + logger.error("Ошибка при отправке уведомления на endpoint $endpoint: ${e.message}") + throw e } - .doOnSuccess { - logger.info("Уведомление успешно отправлено на endpoint: $endpoint") - } - .doOnError { e -> - logger.error("Ошибка при отправке уведомления на endpoint $endpoint: ${e.message}") - } - .onErrorResume { e -> - Mono.error(e) // Пробрасываем ошибку дальше, если нужна обработка выше - } } - fun sendToAll(payload: PushMessage): Mono> { - return subscriptionRepo.findAll() - .flatMap { sub -> + suspend fun sendToAll(payload: PushMessage) { + + subscriptionRepo.findAll().collectList().awaitSingle().forEach { sub -> + + try { sendNotification(sub.endpoint, sub.p256dh, sub.auth, payload) - .then(Mono.just("${sub.user?.username} at endpoint ${sub.endpoint}")) - .onErrorResume { e -> - sub.isActive = false - subscriptionRepo.save(sub).then(Mono.empty()) - } + } catch (e: Exception) { + sub.isActive = false + subscriptionRepo.save(sub).awaitSingle() } - .collectList() // Собираем результаты в список + } } diff --git a/src/main/kotlin/space/luminic/budgerapp/services/UserService.kt b/src/main/kotlin/space/luminic/budgerapp/services/UserService.kt index bd78076..0e3a502 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/UserService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/UserService.kt @@ -29,8 +29,12 @@ class UserService(val userRepo: UserRepo) { .switchIfEmpty(Mono.error(Exception("User not found"))) // Обрабатываем случай, когда пользователь не найден } + suspend fun getUserByTelegramId(telegramId: Long): User? { + return userRepo.findByTgId(telegramId.toString()).awaitSingleOrNull() + } - @Cacheable("users", key = "#username") + + @Cacheable("users", key = "#username") suspend fun getByUserNameWoPass(username: String): User { return userRepo.findByUsernameWOPassword(username).awaitSingleOrNull() ?: throw NotFoundException("User with username: $username not found") diff --git a/src/main/kotlin/space/luminic/budgerapp/utils/ScheduledTasks.kt b/src/main/kotlin/space/luminic/budgerapp/utils/ScheduledTasks.kt index f67dc94..d02a6f4 100644 --- a/src/main/kotlin/space/luminic/budgerapp/utils/ScheduledTasks.kt +++ b/src/main/kotlin/space/luminic/budgerapp/utils/ScheduledTasks.kt @@ -14,7 +14,7 @@ class ScheduledTasks(private val subscriptionService: SubscriptionService, private val logger = LoggerFactory.getLogger(ScheduledTasks::class.java) @Scheduled(cron = "0 30 19 * * *") - fun sendNotificationOfMoneyFilling() { + suspend fun sendNotificationOfMoneyFilling() { subscriptionService.sendToAll( PushMessage( title = "Время заполнять траты!🤑", @@ -23,13 +23,6 @@ class ScheduledTasks(private val subscriptionService: SubscriptionService, badge = "/apple-touch-icon.png", url = "https://luminic.space/transactions/create" ) - ).doOnNext { responses -> - responses.forEach { response -> - logger.info("Уведомление отправлено: $response") - } - }.subscribe( - { logger.info("Все уведомления отправлены.") }, - { error -> logger.error("Ошибка при отправке уведомлений: ${error.message}", error) } ) } diff --git a/src/main/resources/application-dev-local.properties b/src/main/resources/application-dev-local.properties index 4c1fd8d..3d91eb0 100644 --- a/src/main/resources/application-dev-local.properties +++ b/src/main/resources/application-dev-local.properties @@ -18,10 +18,4 @@ spring.datasource.username=familybudget_app spring.datasource.password=FB1q2w3e4r! -# ??????? JDBC -spring.datasource.driver-class-name=org.postgresql.Driver - -# ????????? Hibernate -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -spring.jpa.hibernate.ddl-auto=none - +telegram.bot.token = 6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index c606d9f..c1f8b86 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,27 +1,8 @@ spring.application.name=budger-app - -spring.data.mongodb.host=77.222.32.64 -spring.data.mongodb.port=27017 -spring.data.mongodb.database=budger-app +spring.data.mongodb.uri=mongodb://budger-app:BA1q2w3e4r!@luminic.space:27017/budger-app?authSource=admin&minPoolSize=10&maxPoolSize=100 #spring.data.mongodb.username=budger-app #spring.data.mongodb.password=BA1q2w3e4r! #spring.data.mongodb.authentication-database=admin - - management.endpoints.web.exposure.include=* management.endpoint.health.show-details=always - - - -spring.datasource.url=jdbc:postgresql://213.183.51.243/familybudget_app -spring.datasource.username=familybudget_app -spring.datasource.password=FB1q2w3e4r! - - -# ??????? JDBC -spring.datasource.driver-class-name=org.postgresql.Driver - -# ????????? Hibernate -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -spring.jpa.hibernate.ddl-auto=none - +telegram.bot.token=6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 4ab26ba..1db784f 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -16,5 +16,5 @@ logging.level.org.springframework.data.mongodb.code = DEBUG #management.endpoint.metrics.access=read_only - +telegram.bot.token = 6662300972:AAFXjk_h0AUCy4bORC12UcdXbYnh2QSVKAY diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7fceb7f..894c2e4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -34,3 +34,6 @@ management.endpoints.web.exposure.include=* # Enable Prometheus metrics export management.prometheus.metrics.export.enabled=true +telegram.bot.username = expenses_diary_bot + +