Переписал бота с NestJS/TypeScript на Rust

Стек: teloxide + sqlx + axum + tokio-cron-scheduler.
Вся логика перенесена: /start, /help, /settings, выбор частоты,
cron-рассылка цитат, admin API. Совместимость с существующей БД
сохранена (camelCase колонки). Старый TypeScript-код удалён.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-16 01:55:20 +03:00
parent 0269e62f16
commit b885fd39b9
43 changed files with 4085 additions and 10826 deletions

View File

@@ -1,25 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

View File

@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

85
CLAUDE.md Normal file
View File

@@ -0,0 +1,85 @@
# Mental Health Bot
Telegram-бот на Rust, который отправляет пользователям мотивирующие цитаты с настраиваемой периодичностью.
## Стек технологий
- **Язык**: Rust (edition 2021)
- **Async runtime**: tokio
- **Telegram**: teloxide 0.13
- **База данных**: PostgreSQL через sqlx 0.8
- **HTTP (admin API)**: axum 0.8
- **Планировщик**: tokio-cron-scheduler 0.13
## Структура проекта
```
mental_health/
├── Cargo.toml
├── .env # BOT_TOKEN, DATABASE_URL, ADMIN_PASSWORD, PORT
├── migrations/
│ └── 20260216000000_create_users.sql
├── quotes.json # ~200 мотивирующих цитат на русском языке
└── src/
├── main.rs # Точка входа: pool + bot + axum + scheduler
├── config.rs # Загрузка env vars в struct Config
├── app_state.rs # Shared state: PgPool, Bot, Config, QuotesStore
├── db.rs # SQL-запросы (User struct + CRUD функции)
├── quotes.rs # Загрузка quotes.json, случайная цитата
├── bot.rs # Teloxide: /start, /help, /settings, callback frequency_N
├── scheduler.rs # Cron каждый час — рассылка цитат
└── admin.rs # Axum: POST /admin/send-message
```
## Архитектура runtime
```
tokio::main
├── tokio::spawn(axum) → HTTP-сервер на 0.0.0.0:PORT
├── tokio::spawn(scheduler) → cron "0 0 * * * *"
└── dispatcher.dispatch() → teloxide long-polling (блокирует main)
```
Все три системы разделяют один tokio runtime, один `PgPool` и один `Bot` (через `Arc<AppState>`).
## Переменные окружения
| Переменная | Описание |
|------------------|-----------------------------------|
| `BOT_TOKEN` | Токен Telegram-бота |
| `DATABASE_URL` | Строка подключения к PostgreSQL |
| `ADMIN_PASSWORD` | Пароль для админского API |
| `PORT` | Порт HTTP-сервера (по умолчанию 3000) |
## Ключевая логика
- **Регистрация**: при `/start` бот создаёт/обновляет пользователя в БД через `INSERT ON CONFLICT`
- **Настройки**: пользователь выбирает частоту получения цитат (1, 3, 5, 7, 9, 12 часов) через inline-кнопки
- **Рассылка**: cron каждый час — для каждого пользователя проверяется, прошло ли достаточно времени с последней отправки (с допуском 3 мин), и отправляется случайная цитата
- **Админка**: REST endpoint `POST /admin/send-message` с JSON body `{ userId, password, message }` (camelCase)
## Команды
```bash
cargo build # Сборка
cargo run # Запуск (dev)
cargo build --release # Релизная сборка
cargo clippy # Линтер
cargo fmt # Форматирование
cargo test # Тесты
cargo sqlx prepare # Подготовка офлайн-запросов для CI
```
## Стиль кода
- `cargo fmt` — стандартный rustfmt
- `cargo clippy` — без предупреждений
- Комментарии в коде — на русском языке
## Особенности
- Имена колонок в БД — camelCase в кавычках (`"telegramId"`, `"lastQuoteSentAt"`) для совместимости с Prisma-схемой
- SQL-запросы используют AS-алиасы для маппинга в snake_case поля Rust-структуры
- Цитаты загружаются один раз при старте в `QuotesStore` (в отличие от TypeScript-версии, где файл читался при каждом запросе)
- Постоянная кнопка "⚙️ Настройки" отображается через `KeyboardMarkup::resize_keyboard()`
- Миграция использует `IF NOT EXISTS` — безопасна для существующей таблицы

3400
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "mental_health_bot"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
teloxide = { version = "0.13", features = ["macros"] }
sqlx = { version = "0.8", features = ["postgres", "chrono", "migrate", "runtime-tokio-rustls"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
tokio-cron-scheduler = "0.13"
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"
rand = "0.8"

View File

@@ -1,99 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
"telegramId" BIGINT UNIQUE NOT NULL,
username TEXT,
fio TEXT,
"createdAt" TIMESTAMP NOT NULL DEFAULT NOW(),
frequency INTEGER NOT NULL DEFAULT 1,
"lastQuoteSentAt" TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS users_telegram_id_key ON users ("telegramId");

View File

@@ -1,11 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": [
"**/*.json"
]
}
}

10151
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +0,0 @@
{
"name": "mental_health",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^6.0.1",
"@prisma/client": "^6.19.0",
"nestjs-telegraf": "^2.9.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"telegraf": "^4.16.3"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^6.19.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -1,16 +0,0 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -1,20 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement()) // Внутренний ID базы
telegramId BigInt @unique // ID из Телеграма (BigInt обязателен)
username String? // Никнейм (может не быть)
fio String? // ФИО (Имя + Фамилия)
createdAt DateTime @default(now()) // Дата первого обращения к боту
frequency Int @default(1) // Частота отправки цитат в часах (1, 3, 5, 7, 9, 12)
lastQuoteSentAt DateTime @default(now()) // Время последней отправки цитаты
@@map("users") // Необязательно: делает имя таблицы в БД "users" (множественное число)
}

78
src/admin.rs Normal file
View File

@@ -0,0 +1,78 @@
use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::post;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use teloxide::prelude::*;
use teloxide::types::ChatId;
use crate::app_state::AppState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendMessageRequest {
user_id: i64,
password: String,
message: String,
}
#[derive(Serialize)]
struct SuccessResponse {
success: bool,
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
/// POST /admin/send-message — отправить сообщение пользователю через бота
async fn send_message(
State(state): State<Arc<AppState>>,
Json(body): Json<SendMessageRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
// Проверка пароля
if state.config.admin_password.is_empty() || body.password != state.config.admin_password {
return Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Invalid or missing password".to_string(),
}),
));
}
if body.message.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Missing message".to_string(),
}),
));
}
// Отправка сообщения через Telegram
let chat_id = ChatId(body.user_id);
state
.bot
.send_message(chat_id, &body.message)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Telegram error: {}", e),
}),
)
})?;
Ok(Json(SuccessResponse { success: true }))
}
/// Собрать Axum-роутер для админского API
pub fn admin_router(state: Arc<AppState>) -> Router {
Router::new()
.route("/admin/send-message", post(send_message))
.with_state(state)
}

View File

@@ -1,27 +0,0 @@
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
import { BotService } from '../bot/bot.service';
@Controller('admin')
export class AdminController {
constructor(private readonly botService: BotService) { }
@Post('send-message')
async sendMessage(@Body() body: any) {
// body: { userId: number, password: string, message: string }
const { userId, password, message } = body;
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || password !== adminPassword) {
throw new UnauthorizedException('Invalid or missing password');
}
if (!userId || !message) {
// Maybe Throw BadRequestException?
// For now just return failure or throw
throw new UnauthorizedException('Missing userId or message');
}
await this.botService.sendAdminMessage(Number(userId), message);
return { success: true };
}
}

View File

@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { BotModule } from '../bot/bot.module';
@Module({
imports: [BotModule],
controllers: [AdminController],
})
export class AdminModule { }

View File

@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@@ -1,23 +0,0 @@
import { Module } from '@nestjs/common';
import { TelegrafModule } from 'nestjs-telegraf';
import { ScheduleModule } from '@nestjs/schedule';
import { UsersModule } from './users/users.module';
import { QuotesModule } from './quotes/quotes.module';
import { BotModule } from './bot/bot.module';
import { SchedulerModule } from './scheduler/scheduler.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
ScheduleModule.forRoot(),
TelegrafModule.forRoot({
token: process.env.BOT_TOKEN || 'YOUR_BOT_TOKEN_HERE',
}),
UsersModule,
QuotesModule,
BotModule,
SchedulerModule,
AdminModule,
],
})
export class AppModule { }

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

14
src/app_state.rs Normal file
View File

@@ -0,0 +1,14 @@
use sqlx::PgPool;
use teloxide::prelude::Bot;
use crate::config::Config;
use crate::quotes::QuotesStore;
/// Общее состояние приложения — передаётся во все обработчики
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub bot: Bot,
pub config: Config,
pub quotes: QuotesStore,
}

146
src/bot.rs Normal file
View File

@@ -0,0 +1,146 @@
use std::sync::Arc;
use teloxide::dispatching::UpdateHandler;
use teloxide::prelude::*;
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, KeyboardMarkup};
use teloxide::utils::command::BotCommands;
use crate::app_state::AppState;
use crate::db;
type HandlerResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase")]
pub enum Command {
#[command(description = "Запуск бота")]
Start,
#[command(description = "Справка")]
Help,
#[command(description = "Настройки частоты цитат")]
Settings,
}
/// Постоянная клавиатура с кнопкой "⚙️ Настройки"
fn settings_keyboard() -> KeyboardMarkup {
KeyboardMarkup::new(vec![vec![KeyboardButton::new("⚙️ Настройки")]]).resize_keyboard()
}
/// Inline-клавиатура выбора частоты
fn frequency_keyboard() -> InlineKeyboardMarkup {
InlineKeyboardMarkup::new(vec![
vec![
InlineKeyboardButton::callback("1 час", "frequency_1"),
InlineKeyboardButton::callback("3 часа", "frequency_3"),
],
vec![
InlineKeyboardButton::callback("5 часов", "frequency_5"),
InlineKeyboardButton::callback("7 часов", "frequency_7"),
],
vec![
InlineKeyboardButton::callback("9 часов", "frequency_9"),
InlineKeyboardButton::callback("12 часов", "frequency_12"),
],
])
}
/// Обработка команд /start, /help, /settings
async fn command_handler(
bot: Bot,
msg: Message,
cmd: Command,
state: Arc<AppState>,
) -> HandlerResult {
match cmd {
Command::Start => {
if let Some(user) = msg.from {
let fio = if let Some(ref last) = user.last_name {
format!("{} {}", user.first_name, last)
} else {
user.first_name.clone()
};
db::upsert_user(
&state.pool,
user.id.0 as i64,
Some(&fio),
user.username.as_deref(),
)
.await;
}
bot.send_message(
msg.chat.id,
"Приветствую тебя, мой дорогой друг. Я бот, который будет писать тебе мотивирующие цитаты. Сейчас цитаты буду приходит один раз в час, в настройках можно изменить это время.",
)
.reply_markup(settings_keyboard())
.await?;
}
Command::Help => {
bot.send_message(
msg.chat.id,
"Я буду присылать тебе мотивирующие цитаты. Используй меню для настроек.",
)
.reply_markup(settings_keyboard())
.await?;
}
Command::Settings => {
bot.send_message(msg.chat.id, "Выберите частоту получения цитат:")
.reply_markup(frequency_keyboard())
.await?;
}
}
Ok(())
}
/// Обработка текстового сообщения "⚙️ Настройки" от reply-кнопки
async fn text_message_handler(bot: Bot, msg: Message) -> HandlerResult {
if let Some(text) = msg.text() {
if text == "⚙️ Настройки" {
bot.send_message(msg.chat.id, "Выберите частоту получения цитат:")
.reply_markup(frequency_keyboard())
.await?;
}
}
Ok(())
}
/// Обработка callback-запроса frequency_N
async fn callback_handler(bot: Bot, q: CallbackQuery, state: Arc<AppState>) -> HandlerResult {
if let Some(ref data) = q.data {
if let Some(hours_str) = data.strip_prefix("frequency_") {
if let Ok(hours) = hours_str.parse::<i32>() {
let telegram_id = q.from.id.0 as i64;
db::update_frequency(&state.pool, telegram_id, hours).await;
bot.answer_callback_query(&q.id).await?;
if let Some(msg) = q.regular_message() {
bot.edit_message_text(
msg.chat.id,
msg.id,
format!(
"Отлично! Теперь я буду присылать цитаты каждые {} ч.",
hours
),
)
.await?;
}
}
}
}
Ok(())
}
/// Собрать dptree-обработчик для Dispatcher
pub fn build_handler() -> UpdateHandler<Box<dyn std::error::Error + Send + Sync + 'static>> {
dptree::entry()
.branch(
Update::filter_message()
.branch(
dptree::entry()
.filter_command::<Command>()
.endpoint(command_handler),
)
.branch(dptree::endpoint(text_message_handler)),
)
.branch(Update::filter_callback_query().endpoint(callback_handler))
}

View File

@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { BotService } from './bot.service';
import { UsersModule } from '../users/users.module';
@Module({
imports: [UsersModule],
providers: [BotService],
exports: [BotService],
})
export class BotModule { }

View File

@@ -1,64 +0,0 @@
import { Update, Ctx, Start, Help, On, Message, Command, Action, InjectBot, Hears } from 'nestjs-telegraf';
import { Context, Telegraf, Markup } from 'telegraf';
import { UsersService } from '../users/users.service';
@Update()
export class BotService {
constructor(
private readonly usersService: UsersService,
@InjectBot() private readonly bot: Telegraf<Context>
) { }
@Start()
async start(@Ctx() ctx: Context) {
const user = ctx.from;
if (user) {
await this.usersService.create({
id: user.id,
fullName: `${user.first_name} ${user.last_name || ''}`.trim(),
});
await ctx.reply(
'Приветствую тебя, мой дорогой друг. Я бот, который будет писать тебе мотивирующие цитаты. Сейчас цитаты буду приходит один раз в час, в настройках можно изменить это время.',
Markup.keyboard([
['⚙️ Настройки']
]).resize()
);
}
}
@Help()
async help(@Ctx() ctx: Context) {
await ctx.reply(
'Я буду присылать тебе мотивирующие цитаты. Используй меню для настроек.',
Markup.keyboard([
['⚙️ Настройки']
]).resize()
);
}
@Command('settings')
@Hears('⚙️ Настройки')
async settings(@Ctx() ctx: Context) {
await ctx.reply('Выберите частоту получения цитат:', Markup.inlineKeyboard([
[Markup.button.callback('1 час', 'frequency_1'), Markup.button.callback('3 часа', 'frequency_3')],
[Markup.button.callback('5 часов', 'frequency_5'), Markup.button.callback('7 часов', 'frequency_7')],
[Markup.button.callback('9 часов', 'frequency_9'), Markup.button.callback('12 часов', 'frequency_12')],
]));
}
@Action(/^frequency_(\d+)$/)
async onFrequencySelect(@Ctx() ctx: Context & { match: RegExpExecArray }) {
const user = ctx.from;
if (!user) return;
const hours = parseInt(ctx.match[1]);
await this.usersService.update(user.id, { frequency: hours });
await ctx.answerCbQuery();
await ctx.editMessageText(`Отлично! Теперь я буду присылать цитаты каждые ${hours} ч.`);
}
async sendAdminMessage(userId: number, message: string) {
await this.bot.telegram.sendMessage(userId, message);
}
}

24
src/config.rs Normal file
View File

@@ -0,0 +1,24 @@
/// Конфигурация приложения из переменных окружения
#[derive(Clone)]
pub struct Config {
pub bot_token: String,
pub database_url: String,
pub admin_password: String,
pub port: u16,
}
impl Config {
pub fn from_env() -> Self {
dotenvy::dotenv().ok();
Self {
bot_token: std::env::var("BOT_TOKEN").expect("BOT_TOKEN не задан"),
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL не задан"),
admin_password: std::env::var("ADMIN_PASSWORD").unwrap_or_default(),
port: std::env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT должен быть числом"),
}
}
}

View File

@@ -1 +0,0 @@
[]

94
src/db.rs Normal file
View File

@@ -0,0 +1,94 @@
use chrono::NaiveDateTime;
use sqlx::PgPool;
/// Модель пользователя (совместима с Prisma-схемой)
#[derive(Debug, sqlx::FromRow)]
pub struct User {
pub id: i32,
pub telegram_id: i64,
pub username: Option<String>,
pub fio: Option<String>,
pub created_at: NaiveDateTime,
pub frequency: i32,
pub last_quote_sent_at: NaiveDateTime,
}
/// Получить всех пользователей
pub async fn find_all_users(pool: &PgPool) -> Vec<User> {
sqlx::query_as::<_, User>(
r#"SELECT
id,
"telegramId" AS telegram_id,
username,
fio,
"createdAt" AS created_at,
frequency,
"lastQuoteSentAt" AS last_quote_sent_at
FROM users"#,
)
.fetch_all(pool)
.await
.unwrap_or_default()
}
/// Найти пользователя по Telegram ID
pub async fn find_user_by_telegram_id(pool: &PgPool, telegram_id: i64) -> Option<User> {
sqlx::query_as::<_, User>(
r#"SELECT
id,
"telegramId" AS telegram_id,
username,
fio,
"createdAt" AS created_at,
frequency,
"lastQuoteSentAt" AS last_quote_sent_at
FROM users
WHERE "telegramId" = $1"#,
)
.bind(telegram_id)
.fetch_optional(pool)
.await
.ok()
.flatten()
}
/// Создать или обновить пользователя (upsert)
pub async fn upsert_user(
pool: &PgPool,
telegram_id: i64,
fio: Option<&str>,
username: Option<&str>,
) {
sqlx::query(
r#"INSERT INTO users ("telegramId", fio, username, frequency, "createdAt", "lastQuoteSentAt")
VALUES ($1, $2, $3, 1, NOW(), NOW())
ON CONFLICT ("telegramId")
DO UPDATE SET fio = $2, username = $3"#,
)
.bind(telegram_id)
.bind(fio)
.bind(username)
.execute(pool)
.await
.ok();
}
/// Обновить частоту отправки цитат
pub async fn update_frequency(pool: &PgPool, telegram_id: i64, frequency: i32) {
sqlx::query(r#"UPDATE users SET frequency = $1 WHERE "telegramId" = $2"#)
.bind(frequency)
.bind(telegram_id)
.execute(pool)
.await
.ok();
}
/// Обновить время последней отправки цитаты
pub async fn update_last_quote_sent(pool: &PgPool, telegram_id: i64, sent_at: NaiveDateTime) {
sqlx::query(r#"UPDATE users SET "lastQuoteSentAt" = $1 WHERE "telegramId" = $2"#)
.bind(sent_at)
.bind(telegram_id)
.execute(pool)
.await
.ok();
}

84
src/main.rs Normal file
View File

@@ -0,0 +1,84 @@
mod admin;
mod app_state;
mod bot;
mod config;
mod db;
mod quotes;
mod scheduler;
use std::sync::Arc;
use sqlx::postgres::PgPoolOptions;
use teloxide::prelude::*;
use app_state::AppState;
use config::Config;
use quotes::QuotesStore;
#[tokio::main]
async fn main() {
// Инициализация логирования
tracing_subscriber::fmt::init();
// Загрузка конфигурации
let config = Config::from_env();
tracing::info!("Конфигурация загружена, порт: {}", config.port);
// Подключение к PostgreSQL
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&config.database_url)
.await
.expect("Не удалось подключиться к PostgreSQL");
// Выполнение миграций
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Ошибка выполнения миграций");
tracing::info!("Миграции выполнены");
// Загрузка цитат
let quotes = QuotesStore::load_from_file("quotes.json");
// Инициализация Telegram-бота
let bot = Bot::new(&config.bot_token);
// Общее состояние
let state = Arc::new(AppState {
pool: pool.clone(),
bot: bot.clone(),
config: config.clone(),
quotes: quotes.clone(),
});
// Запуск HTTP-сервера (admin API)
let admin_state = state.clone();
let port = config.port;
tokio::spawn(async move {
let app = admin::admin_router(admin_state);
let addr = format!("0.0.0.0:{}", port);
tracing::info!("HTTP-сервер запущен на {}", addr);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("Не удалось привязать HTTP-порт");
axum::serve(listener, app).await.ok();
});
// Запуск планировщика
tokio::spawn(async move {
if let Err(e) = scheduler::start_scheduler(pool, bot.clone(), quotes).await {
tracing::error!("Ошибка планировщика: {}", e);
}
});
// Запуск Telegram-бота (блокирует main-таск)
tracing::info!("Запуск Telegram-бота (long-polling)...");
let handler = bot::build_handler();
Dispatcher::builder(state.bot.clone(), handler)
.dependencies(dptree::deps![state])
.enable_ctrlc_handler()
.build()
.dispatch()
.await;
}

View File

@@ -1,8 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View File

@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule { }

View File

@@ -1,13 +0,0 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

46
src/quotes.rs Normal file
View File

@@ -0,0 +1,46 @@
use rand::Rng;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct Quote {
pub text: String,
pub author: Option<String>,
}
/// Хранилище цитат — загружается один раз при старте
#[derive(Clone)]
pub struct QuotesStore {
quotes: Vec<Quote>,
}
impl QuotesStore {
/// Загрузить цитаты из JSON-файла
pub fn load_from_file(path: &str) -> Self {
let data = std::fs::read_to_string(path).expect("Не удалось прочитать quotes.json");
let quotes: Vec<Quote> =
serde_json::from_str(&data).expect("Невалидный JSON в quotes.json");
tracing::info!("Загружено {} цитат", quotes.len());
Self { quotes }
}
/// Случайная цитата
pub fn random(&self) -> Option<&Quote> {
if self.quotes.is_empty() {
return None;
}
let idx = rand::thread_rng().gen_range(0..self.quotes.len());
Some(&self.quotes[idx])
}
/// Форматировать цитату для отправки
pub fn format_quote(quote: &Quote) -> String {
let mut msg = format!("\"{}\"", quote.text);
// Проверяем и None, и пустую строку
if let Some(author) = &quote.author {
if !author.is_empty() {
msg.push_str(&format!("\n\n{}", author));
}
}
msg
}
}

View File

@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { QuotesService } from './quotes.service';
@Module({
providers: [QuotesService],
exports: [QuotesService],
})
export class QuotesModule { }

View File

@@ -1,30 +0,0 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
export interface Quote {
text: string;
author?: string;
}
@Injectable()
export class QuotesService {
private readonly filePath = path.resolve(__dirname, '..', 'quotes.json');
private readQuotes(): Quote[] {
if (!fs.existsSync(this.filePath)) {
return [];
}
const data = fs.readFileSync(this.filePath, 'utf8');
return JSON.parse(data);
}
getRandomQuote(): Quote {
const quotes = this.readQuotes();
if (quotes.length === 0) {
return { text: 'No quotes available.', author: 'System' };
}
const randomIndex = Math.floor(Math.random() * quotes.length);
return quotes[randomIndex];
}
}

85
src/scheduler.rs Normal file
View File

@@ -0,0 +1,85 @@
use std::sync::Arc;
use chrono::Utc;
use sqlx::PgPool;
use teloxide::prelude::*;
use teloxide::types::ChatId;
use tokio_cron_scheduler::{Job, JobScheduler};
use crate::db;
use crate::quotes::QuotesStore;
/// Запуск cron-планировщика: каждый час проверяет и отправляет цитаты
pub async fn start_scheduler(
pool: PgPool,
bot: Bot,
quotes: QuotesStore,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let sched = JobScheduler::new().await?;
let pool = Arc::new(pool);
let bot = Arc::new(bot);
let quotes = Arc::new(quotes);
// Cron: каждый час в 0 минут (6-полевый формат: sec min hour day month weekday)
let job = Job::new_async("0 0 * * * *", move |_uuid, _lock| {
let pool = pool.clone();
let bot = bot.clone();
let quotes = quotes.clone();
Box::pin(async move {
tracing::info!("Cron: проверка пользователей для рассылки цитат");
check_and_send_quotes(&pool, &bot, &quotes).await;
})
})?;
sched.add(job).await?;
sched.start().await?;
tracing::info!("Планировщик запущен (каждый час)");
Ok(())
}
/// Проверить всех пользователей и отправить цитаты тем, кому пора
async fn check_and_send_quotes(pool: &PgPool, bot: &Bot, quotes: &QuotesStore) {
let users = db::find_all_users(pool).await;
let now = Utc::now().naive_utc();
for user in users {
let diff_hours = (now - user.last_quote_sent_at).num_minutes() as f64 / 60.0;
// Допуск 3 минуты (0.05 часа) для задержек cron
if diff_hours >= user.frequency as f64 - 0.05 {
send_quote(pool, bot, quotes, &user).await;
}
}
}
/// Отправить случайную цитату пользователю
async fn send_quote(pool: &PgPool, bot: &Bot, quotes: &QuotesStore, user: &db::User) {
let quote = match quotes.random() {
Some(q) => q,
None => {
tracing::warn!("Нет доступных цитат");
return;
}
};
let message = QuotesStore::format_quote(quote);
let chat_id = ChatId(user.telegram_id);
match bot.send_message(chat_id, &message).await {
Ok(_) => {
let now = Utc::now().naive_utc();
db::update_last_quote_sent(pool, user.telegram_id, now).await;
tracing::debug!("Цитата отправлена пользователю {}", user.telegram_id);
}
Err(e) => {
tracing::error!(
"Ошибка отправки цитаты пользователю {}: {}",
user.telegram_id,
e
);
}
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { SchedulerService } from './scheduler.service';
import { UsersModule } from '../users/users.module';
import { QuotesModule } from '../quotes/quotes.module';
@Module({
imports: [UsersModule, QuotesModule],
providers: [SchedulerService],
})
export class SchedulerModule { }

View File

@@ -1,59 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectBot } from 'nestjs-telegraf';
import { Context, Telegraf } from 'telegraf';
import { UsersService } from '../users/users.service';
import { QuotesService } from '../quotes/quotes.service';
import { User } from '@prisma/client';
@Injectable()
export class SchedulerService {
private readonly logger = new Logger(SchedulerService.name);
constructor(
private readonly usersService: UsersService,
private readonly quotesService: QuotesService,
@InjectBot() private readonly bot: Telegraf<Context>,
) { }
// Run every hour
@Cron('0 * * * *')
async handleCron() {
this.logger.debug('Hourly cron triggered. Checking users...');
const users = await this.usersService.findAll();
const now = new Date();
for (const user of users) {
await this.checkAndSendQuote(user, now);
}
}
private async checkAndSendQuote(user: User, now: Date) {
const lastSent = user.lastQuoteSentAt ? new Date(user.lastQuoteSentAt) : new Date(0);
const diffMs = now.getTime() - lastSent.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
// Allow 3 minutes tolerance (0.05 hours) for cron execution delays
if (diffHours >= user.frequency - 0.05) {
await this.sendQuote(user);
// Updating user lastQuoteSentAt to now
// Update expects telegramId. user.telegramId is BigInt.
await this.usersService.update(Number(user.telegramId), { lastQuoteSentAt: now });
}
}
private async sendQuote(user: User) {
try {
const quote = this.quotesService.getRandomQuote();
let message = `"${quote.text}"`;
if (quote.author) {
message += `\n\n- ${quote.author}`;
}
await this.bot.telegram.sendMessage(user.telegramId.toString(), message);
} catch (error) {
this.logger.error(`Failed to send quote to user ${user.telegramId}: ${error.message}`);
}
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule { }

View File

@@ -1,46 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { User } from '@prisma/client';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) { }
async findAll(): Promise<User[]> {
return this.prisma.user.findMany();
}
async findOne(telegramId: number): Promise<User | null> {
return this.prisma.user.findUnique({
where: { telegramId: BigInt(telegramId) },
});
}
async create(user: { id: number; fullName: string; username?: string }): Promise<User> {
return this.prisma.user.upsert({
where: { telegramId: BigInt(user.id) },
update: {
fio: user.fullName,
username: user.username,
},
create: {
telegramId: BigInt(user.id),
fio: user.fullName,
username: user.username,
frequency: 1,
// createdAt and lastQuoteSentAt have defaults
},
});
}
async update(telegramId: number, updateData: Partial<User> & { frequency?: number }): Promise<User> {
// We need to be careful with BigInt serialization if we blindly spread updateData
// For now, we only expect frequency updates from the bot.
const data: any = { ...updateData };
return this.prisma.user.update({
where: { telegramId: BigInt(telegramId) },
data: data,
});
}
}

View File

@@ -1,24 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -1,9 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}