Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/main/kotlin/com/github/djaler/evilbot/Application.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,6 +19,7 @@ import org.springframework.scheduling.annotation.EnableScheduling
@EnableScheduling
@EnableCaching
@EnableConfigurationProperties(
BackupProperties::class,
CacheProperties::class,
TelegramProperties::class,
BotProperties::class,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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 * * ?",
)
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<String> {
val tables = mutableListOf<String>()
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<String> {
val columns = mutableListOf<String>()
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
}
}
2 changes: 2 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
backup.admin-telegram-id=
backup.cron=0 0 3 * * ?
telegram.bot.token=
fixer.api.key=
locationiq.api.key=
Expand Down
Loading