Переписал бота с 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:
25
.eslintrc.js
25
.eslintrc.js
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
85
CLAUDE.md
Normal file
85
CLAUDE.md
Normal 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
3400
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal 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"
|
||||
99
README.md
99
README.md
@@ -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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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).
|
||||
11
migrations/20260216000000_create_users.sql
Normal file
11
migrations/20260216000000_create_users.sql
Normal 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");
|
||||
@@ -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
10151
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
74
package.json
74
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
@@ -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
78
src/admin.rs
Normal 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)
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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
14
src/app_state.rs
Normal 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
146
src/bot.rs
Normal 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))
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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
24
src/config.rs
Normal 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 должен быть числом"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
94
src/db.rs
Normal file
94
src/db.rs
Normal 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
84
src/main.rs
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule { }
|
||||
@@ -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
46
src/quotes.rs
Normal 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) = "e.author {
|
||||
if !author.is_empty() {
|
||||
msg.push_str(&format!("\n\n— {}", author));
|
||||
}
|
||||
}
|
||||
msg
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { QuotesService } from './quotes.service';
|
||||
|
||||
@Module({
|
||||
providers: [QuotesService],
|
||||
exports: [QuotesService],
|
||||
})
|
||||
export class QuotesModule { }
|
||||
@@ -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
85
src/scheduler.rs
Normal 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, "es).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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user