diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a241bee --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Kotlin/Spring Boot Telegram bot ("Evil Bot") using `dev.inmo:tgbotapi`. Backed by PostgreSQL + Redis, deployed via Docker Compose. + +## Build & Run + +Java 11 is required (see Dockerfile and CI). Determine JAVA_HOME before first Gradle run. + +```bash +./gradlew build # Build + run tests +./gradlew bootRun # Run locally +./gradlew bootJar # Create executable JAR +``` + +No tests exist currently (`src/test` is empty). + +## Architecture + +### Handler Chain Pattern + +All Telegram updates flow through `UpdatesManager`, which iterates over a sorted list of `UpdateHandler` implementations. Each handler returns `true` if it handled the update (stops the chain) or `false` to pass to the next handler. + +**Handler base classes** (`handlers/base/`): +- `UpdateHandler` — interface with `handleUpdate()`, `updateType`, and `order` (default 1) +- `CommandHandler` — abstract class for `/command` handlers; parses command args, registers bot commands with scopes +- `CommonMessageHandler`, `MessageHandler` — handle general messages with filters +- `CallbackQueryHandler`, `NewMemberHandler`, `PollAnswerHandler` — specialized handlers + +**Adding a new command**: extend `CommandHandler`, provide command name(s), description, and scope. Implement `handleCommand(message, args)`. + +**Command scopes** control where the command appears in Telegram's command menu. Set via `commandScope` parameter in `CommandHandler` constructor: +- `BotCommandScope.Default` — visible everywhere (default) +- `BotCommandScope.AllGroupChats` — only in groups +- `BotCommandScope.AllChatAdministrators` — only for group admins +- `BotCommandScopeChat(chatId)` — only in a specific chat (e.g. private chat with a specific user) + +Commands are registered in `BotInitializer.updateCommands()` via `setMyCommands()` per scope. `CommandService.normalizeCommands()` handles scope inheritance (`AllChatAdministrators` inherits from `AllGroupChats`, which inherits from `Default`). + +### Layers + +- `handlers/` — Telegram update handlers (commands in `handlers/commands/`) +- `service/` — business logic +- `repository/` — Spring Data JPA repositories +- `entity/` — JPA entities +- `clients/` — external API clients (Fixer currency, LocationIQ, VK Cloud voice, Yandex GPT, CAS anti-spam) +- `filters/` — message/query filter predicates composable with `and`/`or` +- `components/` — Spring components (`UpdatesManager`, `BotInitializer`, `ExceptionsManager`, schedulers) + +### Database + +PostgreSQL with Flyway migrations in `src/main/resources/db/migration/`. Redis for caching (1h default TTL). + +### Configuration + +All config via `application.properties` + environment variables. Custom properties use `@ConfigurationProperties` + `@ConstructorBinding` data classes (registered in `@EnableConfigurationProperties` in `Application.kt`). Key properties: `telegram.bot.token`, `backup.admin-telegram-id`, `backup.cron`, `fixer.api.key`, `locationiq.api.key`, `vk.api.key`, `yandex.api.token`, `video.download.enabled`. + +### Scheduled Tasks + +`@EnableScheduling` is active. Schedulers in `components/` use `@Scheduled` with `GlobalScope.launch` for coroutine support. External processes (ffmpeg, yt-dlp) are run via `ProcessBuilder`. + +### Deployment + +- Docker Compose: `docker-compose up` (bot + PostgreSQL + Redis) +- CI: GitHub Actions — `./gradlew build` on PRs, SSH deploy on push to master \ No newline at end of file diff --git a/src/main/kotlin/com/github/djaler/evilbot/Application.kt b/src/main/kotlin/com/github/djaler/evilbot/Application.kt index 847e6c1..15f2fe4 100644 --- a/src/main/kotlin/com/github/djaler/evilbot/Application.kt +++ b/src/main/kotlin/com/github/djaler/evilbot/Application.kt @@ -1,5 +1,6 @@ package com.github.djaler.evilbot +import com.github.djaler.evilbot.config.BackupProperties import com.github.djaler.evilbot.config.BotProperties import com.github.djaler.evilbot.config.CacheProperties import com.github.djaler.evilbot.config.TelegramProperties @@ -18,6 +19,7 @@ import org.springframework.scheduling.annotation.EnableScheduling @EnableScheduling @EnableCaching @EnableConfigurationProperties( + BackupProperties::class, CacheProperties::class, TelegramProperties::class, BotProperties::class, diff --git a/src/main/kotlin/com/github/djaler/evilbot/components/BackupScheduler.kt b/src/main/kotlin/com/github/djaler/evilbot/components/BackupScheduler.kt new file mode 100644 index 0000000..856cfda --- /dev/null +++ b/src/main/kotlin/com/github/djaler/evilbot/components/BackupScheduler.kt @@ -0,0 +1,56 @@ +package com.github.djaler.evilbot.components + +import com.github.djaler.evilbot.clients.SentryClient +import com.github.djaler.evilbot.config.BackupProperties +import com.github.djaler.evilbot.service.DatabaseBackupService +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.media.sendDocument +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.requests.abstracts.asMultipartFile +import dev.inmo.tgbotapi.types.toChatId +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.apache.logging.log4j.LogManager +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class BackupScheduler( + private val databaseBackupService: DatabaseBackupService, + private val requestsExecutor: RequestsExecutor, + private val backupProperties: BackupProperties, + private val sentryClient: SentryClient, +) { + companion object { + private val log = LogManager.getLogger() + } + + @Scheduled(cron = "\${backup.cron}") + fun scheduledBackup() { + GlobalScope.launch { + performBackup() + } + } + + suspend fun performBackup() { + val chatId = backupProperties.adminTelegramId.toChatId() + var file: java.io.File? = null + + try { + file = databaseBackupService.createDump() + requestsExecutor.sendDocument(chatId, document = file.asMultipartFile()) + log.info("Backup sent to admin (telegramId={})", backupProperties.adminTelegramId) + } catch (e: Exception) { + log.error("Failed to perform backup", e) + sentryClient.captureException(e) + + try { + requestsExecutor.sendMessage(chatId, "Ошибка при создании бэкапа: ${e.message}") + } catch (sendError: Exception) { + log.error("Failed to notify admin about backup error", sendError) + } + } finally { + file?.delete() + } + } +} diff --git a/src/main/kotlin/com/github/djaler/evilbot/config/BackupProperties.kt b/src/main/kotlin/com/github/djaler/evilbot/config/BackupProperties.kt new file mode 100644 index 0000000..457c30a --- /dev/null +++ b/src/main/kotlin/com/github/djaler/evilbot/config/BackupProperties.kt @@ -0,0 +1,11 @@ +package com.github.djaler.evilbot.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding + +@ConfigurationProperties(prefix = "backup") +@ConstructorBinding +data class BackupProperties( + val adminTelegramId: Long, + val cron: String = "0 0 3 * * ?", +) diff --git a/src/main/kotlin/com/github/djaler/evilbot/handlers/commands/BackupCommandHandler.kt b/src/main/kotlin/com/github/djaler/evilbot/handlers/commands/BackupCommandHandler.kt new file mode 100644 index 0000000..0f17220 --- /dev/null +++ b/src/main/kotlin/com/github/djaler/evilbot/handlers/commands/BackupCommandHandler.kt @@ -0,0 +1,30 @@ +package com.github.djaler.evilbot.handlers.commands + +import com.github.djaler.evilbot.components.BackupScheduler +import com.github.djaler.evilbot.config.BackupProperties +import com.github.djaler.evilbot.handlers.base.CommandHandler +import dev.inmo.tgbotapi.types.chat.ExtendedBot +import dev.inmo.tgbotapi.types.commands.BotCommandScopeChat +import dev.inmo.tgbotapi.types.message.content.TextMessage +import dev.inmo.tgbotapi.types.toChatId +import org.springframework.stereotype.Component + +@Component +class BackupCommandHandler( + botInfo: ExtendedBot, + private val backupScheduler: BackupScheduler, + private val backupProperties: BackupProperties +) : CommandHandler( + botInfo, + command = arrayOf("backup"), + commandDescription = "сделать бэкап базы данных", + commandScope = BotCommandScopeChat(backupProperties.adminTelegramId.toChatId()) +) { + override suspend fun handleCommand(message: TextMessage, args: String?) { + if (message.chat.id.chatId != backupProperties.adminTelegramId) { + return + } + + backupScheduler.performBackup() + } +} diff --git a/src/main/kotlin/com/github/djaler/evilbot/service/DatabaseBackupService.kt b/src/main/kotlin/com/github/djaler/evilbot/service/DatabaseBackupService.kt new file mode 100644 index 0000000..0597f32 --- /dev/null +++ b/src/main/kotlin/com/github/djaler/evilbot/service/DatabaseBackupService.kt @@ -0,0 +1,85 @@ +package com.github.djaler.evilbot.service + +import org.apache.logging.log4j.LogManager +import org.postgresql.copy.CopyManager +import org.postgresql.jdbc.PgConnection +import org.springframework.stereotype.Service +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import javax.sql.DataSource + +@Service +class DatabaseBackupService( + private val dataSource: DataSource +) { + companion object { + private val log = LogManager.getLogger() + private const val EXCLUDED_TABLE = "flyway_schema_history" + } + + fun createDump(): File { + val file = Files.createTempFile("backup-", ".sql").toFile() + + try { + dataSource.connection.use { connection -> + val pgConnection = connection.unwrap(PgConnection::class.java) + val copyManager = CopyManager(pgConnection) + val tables = getTableNames(pgConnection) + + log.info("Backing up {} tables: {}", tables.size, tables) + + file.bufferedWriter().use { writer -> + writer.write("-- evil-bot database backup\n\n") + + for (table in tables) { + val columns = getColumnNames(pgConnection, table) + val columnList = columns.joinToString(", ") + + writer.write("COPY $table ($columnList) FROM stdin;\n") + writer.flush() + + val buffer = ByteArrayOutputStream() + copyManager.copyOut("COPY $table ($columnList) TO STDOUT", buffer) + writer.write(buffer.toString(Charsets.UTF_8.name())) + + writer.write("\\.\n\n") + } + } + } + } catch (e: Exception) { + file.delete() + throw e + } + + return file + } + + private fun getTableNames(connection: PgConnection): List { + val tables = mutableListOf() + connection.createStatement().use { stmt -> + stmt.executeQuery( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != '$EXCLUDED_TABLE' ORDER BY tablename" + ).use { rs -> + while (rs.next()) { + tables.add(rs.getString("tablename")) + } + } + } + return tables + } + + private fun getColumnNames(connection: PgConnection, table: String): List { + val columns = mutableListOf() + connection.createStatement().use { stmt -> + stmt.executeQuery( + "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '$table' ORDER BY ordinal_position" + ).use { rs -> + while (rs.next()) { + columns.add(rs.getString("column_name")) + } + } + } + return columns + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 75bd82b..4b58b7c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,5 @@ +backup.admin-telegram-id= +backup.cron=0 0 3 * * ? telegram.bot.token= fixer.api.key= locationiq.api.key=