feat: Initialize Yaltopia Telegram bot project with core architecture
This commit is contained in:
commit
23a455db95
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Telegram Bot Configuration
|
||||||
|
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
API_BASE_URL=http://localhost:3000/api
|
||||||
101
.gitignore
vendored
Normal file
101
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
public
|
||||||
|
|
||||||
|
# Storybook build outputs
|
||||||
|
.out
|
||||||
|
.storybook-out
|
||||||
|
|
||||||
|
# Temporary folders
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Development Documentation
|
||||||
|
|
||||||
|
docs/
|
||||||
110
README.md
Normal file
110
README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Yaltopia Telegram Bot
|
||||||
|
|
||||||
|
A feature-rich Telegram bot for property listings built with TypeScript and organized using a feature-first architecture.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📱 **Phone-based Authentication**: Users share phone number to login/register
|
||||||
|
- 🏠 **Property Management**: Browse, view, and add properties
|
||||||
|
- 👤 **User Registration**: Complete registration flow with validation
|
||||||
|
- 🔐 **Secure Login**: Password-based authentication for existing users
|
||||||
|
- 📋 **Property Listings**: View all available properties
|
||||||
|
- 🏘️ **My Properties**: Manage user's own property listings
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This project follows a **feature-first approach** for better scalability:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── shared/ # Shared utilities and services
|
||||||
|
│ ├── types/ # Common TypeScript interfaces
|
||||||
|
│ └── services/ # Shared services (API, Session)
|
||||||
|
├── features/ # Feature modules
|
||||||
|
│ ├── auth/ # Authentication feature
|
||||||
|
│ └── properties/ # Property management feature
|
||||||
|
└── bot/ # Bot orchestration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Environment setup:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and add your Telegram bot token:
|
||||||
|
```
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
|
API_BASE_URL=http://localhost:3000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Build the project:**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Start the bot:**
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
For development:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bot Flow
|
||||||
|
|
||||||
|
### 1. Authentication Flow
|
||||||
|
- User starts with `/start`
|
||||||
|
- Bot requests phone number
|
||||||
|
- If phone exists → Login with password
|
||||||
|
- If new user → Registration flow (name, email, password, confirm)
|
||||||
|
|
||||||
|
### 2. Main Menu (After Login)
|
||||||
|
- 🏘️ **Browse Properties**: View all available properties
|
||||||
|
- 🏠 **My Properties**: View user's own listings
|
||||||
|
- ➕ **Add Property**: Create new property listing
|
||||||
|
- 👤 **Profile**: View user profile information
|
||||||
|
|
||||||
|
### 3. Property Creation Flow
|
||||||
|
- Title → Description → Type (Rent/Sale) → Price → Area → Rooms → Toilets → Subcity → House Type
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
The bot integrates with your existing backend APIs:
|
||||||
|
|
||||||
|
- `POST /telegram-auth/telegram-register` - User registration
|
||||||
|
- `POST /telegram-auth/telegram-login` - User login
|
||||||
|
- `GET /telegram-auth/phone/{phone}/check` - Check phone existence
|
||||||
|
- `GET /telegram-auth/validate-email/{email}` - Email validation
|
||||||
|
- `GET /listings` - Get all properties
|
||||||
|
- `POST /listings` - Create new property
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
- `npm run build` - Compile TypeScript
|
||||||
|
- `npm run dev` - Run in development mode with ts-node
|
||||||
|
- `npm run watch` - Watch mode for TypeScript compilation
|
||||||
|
- `npm start` - Run compiled JavaScript
|
||||||
|
|
||||||
|
### Adding New Features
|
||||||
|
|
||||||
|
1. Create feature directory in `src/features/`
|
||||||
|
2. Add service class for API interactions
|
||||||
|
3. Add handler class for bot interactions
|
||||||
|
4. Integrate in main bot class (`src/bot/bot.ts`)
|
||||||
|
|
||||||
|
## Getting Your Bot Token
|
||||||
|
|
||||||
|
1. Message [@BotFather](https://t.me/botfather) on Telegram
|
||||||
|
2. Use `/newbot` command
|
||||||
|
3. Follow the setup process
|
||||||
|
4. Copy the token to your `.env` file
|
||||||
2733
package-lock.json
generated
Normal file
2733
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "yaltopia-telegram-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Yaltopia property listing Telegram bot",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "ts-node src/index.ts",
|
||||||
|
"watch": "tsc -w"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/node-telegram-bot-api": "^0.64.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"ts-node": "^10.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
370
src/bot/bot.ts
Normal file
370
src/bot/bot.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import { ApiService } from '../shared/services/api.service';
|
||||||
|
import { SessionService } from '../shared/services/session.service';
|
||||||
|
import { AuthService } from '../features/auth/auth.service';
|
||||||
|
import { AuthHandler } from '../features/auth/auth.handler';
|
||||||
|
import { PropertiesService } from '../features/properties/properties.service';
|
||||||
|
import { PropertiesHandler } from '../features/properties/properties.handler';
|
||||||
|
import { SystemMonitor } from '../shared/monitoring/system-monitor';
|
||||||
|
import { Logger, LogLevel } from '../shared/monitoring/logger';
|
||||||
|
import { AdminNotifier } from '../shared/monitoring/admin-notifier';
|
||||||
|
|
||||||
|
export class YaltopiaBot {
|
||||||
|
private bot: TelegramBot;
|
||||||
|
private apiService: ApiService;
|
||||||
|
private sessionService: SessionService;
|
||||||
|
private authService: AuthService;
|
||||||
|
private authHandler: AuthHandler;
|
||||||
|
private propertiesService: PropertiesService;
|
||||||
|
private propertiesHandler: PropertiesHandler;
|
||||||
|
private systemMonitor: SystemMonitor;
|
||||||
|
private logger: Logger;
|
||||||
|
private adminNotifier: AdminNotifier;
|
||||||
|
|
||||||
|
constructor(token: string, apiBaseUrl: string, adminChatIds: number[] = []) {
|
||||||
|
this.bot = new TelegramBot(token, { polling: true });
|
||||||
|
this.apiService = new ApiService(apiBaseUrl);
|
||||||
|
this.sessionService = new SessionService();
|
||||||
|
|
||||||
|
// Initialize monitoring
|
||||||
|
this.logger = new Logger(LogLevel.INFO);
|
||||||
|
this.systemMonitor = new SystemMonitor({ adminChatId: adminChatIds[0] });
|
||||||
|
this.adminNotifier = new AdminNotifier(this.bot, { adminChatIds });
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
this.authService = new AuthService(this.apiService);
|
||||||
|
this.propertiesService = new PropertiesService(this.apiService);
|
||||||
|
|
||||||
|
// Initialize handlers
|
||||||
|
this.authHandler = new AuthHandler(this.bot, this.authService, this.sessionService, this.apiService);
|
||||||
|
this.propertiesHandler = new PropertiesHandler(this.bot, this.propertiesService, this.sessionService);
|
||||||
|
|
||||||
|
this.setupHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupHandlers(): void {
|
||||||
|
// Start command
|
||||||
|
this.bot.onText(/\/start/, async (msg) => {
|
||||||
|
this.systemMonitor.trackUserActivity(msg.chat.id, 'start', true);
|
||||||
|
this.logger.userAction(msg.chat.id, 'start', true);
|
||||||
|
await this.authHandler.handleStart(msg.chat.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin commands
|
||||||
|
this.bot.onText(/\/(status|metrics|errors|users|restart)(.*)/, async (msg, match) => {
|
||||||
|
if (match) {
|
||||||
|
const command = match[1];
|
||||||
|
const args = match[2] ? match[2].trim().split(' ').filter(arg => arg) : [];
|
||||||
|
await this.adminNotifier.handleAdminCommand(msg.chat.id, `/${command}`, args);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contact handler (phone number)
|
||||||
|
this.bot.on('contact', async (msg) => {
|
||||||
|
if (msg.contact && msg.contact.phone_number) {
|
||||||
|
this.systemMonitor.trackUserActivity(msg.chat.id, 'phone_shared', true);
|
||||||
|
this.logger.userAction(msg.chat.id, 'phone_shared', true, { phone: msg.contact.phone_number });
|
||||||
|
await this.authHandler.handlePhoneNumber(msg.chat.id, msg.contact.phone_number);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Text message handler
|
||||||
|
this.bot.on('message', async (msg) => {
|
||||||
|
if (msg.text && !msg.text.startsWith('/') && !msg.contact) {
|
||||||
|
await this.handleTextMessage(msg.chat.id, msg.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback query handler for inline buttons
|
||||||
|
this.bot.on('callback_query', async (callbackQuery) => {
|
||||||
|
await this.propertiesHandler.handlePropertyCallback(callbackQuery);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
this.bot.on('polling_error', (error) => {
|
||||||
|
this.logger.error('Telegram polling error', undefined, { error: error.message }, error);
|
||||||
|
this.systemMonitor.trackError(`Polling error: ${error.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTextMessage(chatId: number, text: string): Promise<void> {
|
||||||
|
const session = this.sessionService.getSession(chatId);
|
||||||
|
|
||||||
|
// Restore access token if user is logged in
|
||||||
|
if (session.user?.access_token) {
|
||||||
|
this.apiService.setAccessToken(session.user.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`💬 User ${chatId} sent message: "${text}" (step: ${session.step})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (session.step) {
|
||||||
|
case 'LOGIN_PASSWORD':
|
||||||
|
await this.authHandler.handleLoginPassword(chatId, text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'REGISTER_NAME':
|
||||||
|
await this.authHandler.handleRegistrationName(chatId, text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'REGISTER_EMAIL':
|
||||||
|
await this.authHandler.handleRegistrationEmail(chatId, text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'REGISTER_PASSWORD':
|
||||||
|
await this.authHandler.handleRegistrationPassword(chatId, text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'REGISTER_CONFIRM_PASSWORD':
|
||||||
|
await this.authHandler.handleRegistrationConfirmPassword(chatId, text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'LOGGED_IN':
|
||||||
|
await this.handleLoggedInCommands(chatId, text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Property creation steps
|
||||||
|
case 'ADD_PROPERTY_TITLE':
|
||||||
|
if (text === '❌ Cancel') {
|
||||||
|
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handlePropertyTitle(chatId, text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ADD_PROPERTY_DESCRIPTION':
|
||||||
|
if (text === '❌ Cancel') {
|
||||||
|
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handlePropertyDescription(chatId, text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ADD_PROPERTY_TYPE':
|
||||||
|
if (text === '❌ Cancel') {
|
||||||
|
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handlePropertyType(chatId, text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ADD_PROPERTY_PRICE':
|
||||||
|
if (text === '❌ Cancel') {
|
||||||
|
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handlePropertyPrice(chatId, text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ADD_PROPERTY_AREA':
|
||||||
|
if (text === '❌ Cancel') {
|
||||||
|
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handlePropertyArea(chatId, text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ADD_PROPERTY_ROOMS':
|
||||||
|
if (text === '❌ Cancel') {
|
||||||
|
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handlePropertyRooms(chatId, text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ADD_PROPERTY_TOILETS':
|
||||||
|
if (text === '❌ Cancel') {
|
||||||
|
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handlePropertyToilets(chatId, text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ADD_PROPERTY_SUBCITY':
|
||||||
|
if (text === '❌ Cancel') {
|
||||||
|
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handlePropertySubcity(chatId, text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ADD_PROPERTY_HOUSE_TYPE':
|
||||||
|
if (text === '❌ Cancel') {
|
||||||
|
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handlePropertyHouseType(chatId, text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Edit property steps
|
||||||
|
case 'EDIT_PROPERTY_TITLE':
|
||||||
|
if (text === '❌ Cancel Edit') {
|
||||||
|
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handleEditPropertyValue(chatId, 'title', text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'EDIT_PROPERTY_DESCRIPTION':
|
||||||
|
if (text === '❌ Cancel Edit') {
|
||||||
|
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handleEditPropertyValue(chatId, 'description', text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'EDIT_PROPERTY_PRICE':
|
||||||
|
if (text === '❌ Cancel Edit') {
|
||||||
|
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handleEditPropertyValue(chatId, 'price', text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'EDIT_PROPERTY_AREA':
|
||||||
|
if (text === '❌ Cancel Edit') {
|
||||||
|
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handleEditPropertyValue(chatId, 'area', text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'EDIT_PROPERTY_ROOMS':
|
||||||
|
if (text === '❌ Cancel Edit') {
|
||||||
|
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handleEditPropertyValue(chatId, 'rooms', text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'EDIT_PROPERTY_TOILETS':
|
||||||
|
if (text === '❌ Cancel Edit') {
|
||||||
|
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handleEditPropertyValue(chatId, 'toilets', text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'EDIT_PROPERTY_LOCATION':
|
||||||
|
if (text === '❌ Cancel Edit') {
|
||||||
|
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handleEditPropertyValue(chatId, 'subcity', text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'EDIT_PROPERTY_HOUSE_TYPE':
|
||||||
|
if (text === '❌ Cancel Edit') {
|
||||||
|
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||||
|
} else if (text === '🚪 Logout') {
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
} else {
|
||||||
|
await this.propertiesHandler.handleEditPropertyValue(chatId, 'houseType', text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`❓ User ${chatId} in unknown step: ${session.step}`);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'Please start by typing /start'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`💥 Error handling message for user ${chatId}:`, error);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Something went wrong. Please try again or type /start to restart.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleLoggedInCommands(chatId: number, text: string): Promise<void> {
|
||||||
|
switch (text) {
|
||||||
|
case '🏘️ Browse Properties':
|
||||||
|
await this.propertiesHandler.handleBrowseProperties(chatId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '🏠 My Properties':
|
||||||
|
await this.propertiesHandler.handleMyProperties(chatId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '➕ Add Property':
|
||||||
|
await this.propertiesHandler.handleAddProperty(chatId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '👤 Profile':
|
||||||
|
await this.handleProfile(chatId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '🚪 Logout':
|
||||||
|
await this.authHandler.handleLogout(chatId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'Please select an option from the menu below:'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleProfile(chatId: number): Promise<void> {
|
||||||
|
const session = this.sessionService.getSession(chatId);
|
||||||
|
|
||||||
|
if (!session.user) {
|
||||||
|
await this.bot.sendMessage(chatId, '❌ User not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = session.user;
|
||||||
|
console.log('🔍 [DEBUG] Profile - User object:', JSON.stringify(user, null, 2));
|
||||||
|
|
||||||
|
const profileMessage =
|
||||||
|
`👤 Your Profile\n\n` +
|
||||||
|
`📛 Name: ${user.name || 'Not available'}\n` +
|
||||||
|
`📧 Email: ${user.email || 'Not available'}\n` +
|
||||||
|
`📱 Phone: ${user.phone || 'Not available'}\n` +
|
||||||
|
`👔 Role: ${user.role || 'Not available'}`;
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId, profileMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
console.log('🤖 Yaltopia Bot started successfully! [VERSION 2.0 - WITH VALIDATION]');
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.bot.stopPolling();
|
||||||
|
console.log('🤖 Yaltopia Bot stopped.');
|
||||||
|
}
|
||||||
|
}
|
||||||
265
src/features/auth/auth.handler.ts
Normal file
265
src/features/auth/auth.handler.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { SessionService } from '../../shared/services/session.service';
|
||||||
|
import { ApiService } from '../../shared/services/api.service';
|
||||||
|
|
||||||
|
export class AuthHandler {
|
||||||
|
constructor(
|
||||||
|
private bot: TelegramBot,
|
||||||
|
private authService: AuthService,
|
||||||
|
private sessionService: SessionService,
|
||||||
|
private apiService: ApiService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleStart(chatId: number): Promise<void> {
|
||||||
|
console.log(`🚀 User ${chatId} started the bot`);
|
||||||
|
this.sessionService.setSessionStep(chatId, 'PHONE');
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'🏠 Welcome to Yaltopia Property Bot!\n\n' +
|
||||||
|
'Please share your phone number to get started.',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: 'Share Phone Number', request_contact: true }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePhoneNumber(chatId: number, phone: string): Promise<void> {
|
||||||
|
console.log(`📱 User ${chatId} shared phone: ${phone}`);
|
||||||
|
|
||||||
|
// Normalize phone number format - ensure it starts with +
|
||||||
|
let normalizedPhone = phone;
|
||||||
|
if (!phone.startsWith('+')) {
|
||||||
|
normalizedPhone = '+' + phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📱 Normalized phone: ${normalizedPhone}`);
|
||||||
|
|
||||||
|
const phoneCheck = await this.authService.checkPhoneExists(normalizedPhone);
|
||||||
|
console.log(`📱 Phone check result for ${chatId}:`, phoneCheck);
|
||||||
|
|
||||||
|
if (!phoneCheck.exists) {
|
||||||
|
// Scenario 3: Phone number doesn't exist - proceed with registration
|
||||||
|
console.log(`📝 User ${chatId} - phone not found, directing to registration`);
|
||||||
|
this.sessionService.setSessionStep(chatId, 'REGISTER_NAME', { phone: normalizedPhone });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'📝 New user! Let\'s create your AGENT account.\n\n' +
|
||||||
|
'Please enter your full name:',
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
} else if (phoneCheck.exists && phoneCheck.isAgent) {
|
||||||
|
// Scenario 1: Phone exists and user is AGENT - proceed with login
|
||||||
|
console.log(`🔐 User ${chatId} - AGENT account found, directing to login`);
|
||||||
|
this.sessionService.setSessionStep(chatId, 'LOGIN_PASSWORD', { phone: normalizedPhone });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'<27> AGENT aoccount found! Please enter your password:',
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
} else if (phoneCheck.exists && !phoneCheck.isAgent) {
|
||||||
|
// Scenario 2: Phone exists but user has different role - contact admin (don't reveal role)
|
||||||
|
console.log(`❌ User ${chatId} - account exists with role ${phoneCheck.userRole}, not AGENT`);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`❌ Account Access Restricted\n\n` +
|
||||||
|
'📞 Please contact the administrator for assistance or use a different phone number.',
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback case
|
||||||
|
console.log(`❓ User ${chatId} - unexpected phone check result`);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Unable to verify phone number. Please try again or contact support.',
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLoginPassword(chatId: number, password: string): Promise<void> {
|
||||||
|
console.log(`🔐 User ${chatId} attempting login`);
|
||||||
|
|
||||||
|
const session = this.sessionService.getSession(chatId);
|
||||||
|
const phone = session.data.phone;
|
||||||
|
|
||||||
|
console.log(`🔐 Login attempt - Phone: ${phone}, Password length: ${password.length}`);
|
||||||
|
|
||||||
|
const result = await this.authService.login(phone, password);
|
||||||
|
|
||||||
|
if (result.success && result.user) {
|
||||||
|
console.log(`✅ User ${chatId} login successful, role: ${result.user.role}`);
|
||||||
|
|
||||||
|
// Additional role check after successful login
|
||||||
|
if (result.user.role !== 'AGENT') {
|
||||||
|
console.log(`❌ User ${chatId} denied access - role is ${result.user.role}, not AGENT`);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Access denied. This bot is only available for AGENT accounts.\n\n' +
|
||||||
|
'Please contact support if you believe this is an error.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionService.updateSession(chatId, {
|
||||||
|
step: 'LOGGED_IN',
|
||||||
|
user: result.user
|
||||||
|
});
|
||||||
|
await this.showMainMenu(chatId, result.user.name);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ User ${chatId} login failed: ${result.message}`);
|
||||||
|
|
||||||
|
// Check if it might be an account status issue
|
||||||
|
if (result.message?.toLowerCase().includes('pending') ||
|
||||||
|
result.message?.toLowerCase().includes('inactive') ||
|
||||||
|
result.message?.toLowerCase().includes('disabled')) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'⏳ Your account is pending activation.\n\n' +
|
||||||
|
'Please contact the administrator to activate your account before you can login.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Invalid password. Please try again:\n\n' +
|
||||||
|
'💡 Tip: Make sure you\'re using the same password you used during registration.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRegistrationName(chatId: number, name: string): Promise<void> {
|
||||||
|
console.log(`📝 User ${chatId} entered name: ${name}`);
|
||||||
|
this.sessionService.setSessionStep(chatId, 'REGISTER_EMAIL', { name });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Name saved!\n\n' +
|
||||||
|
'Please enter your email address:'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRegistrationEmail(chatId: number, email: string): Promise<void> {
|
||||||
|
console.log(`📧 User ${chatId} entered email: ${email}`);
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
console.log(`❌ User ${chatId} entered invalid email format`);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Invalid email format. Please enter a valid email:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await this.authService.validateEmail(email);
|
||||||
|
if (!isValid) {
|
||||||
|
console.log(`❌ User ${chatId} email validation failed`);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Email already exists or is invalid. Please try another:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ User ${chatId} email validated successfully`);
|
||||||
|
this.sessionService.setSessionStep(chatId, 'REGISTER_PASSWORD', { email });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Email saved!\n\n' +
|
||||||
|
'Please create a password (minimum 6 characters):'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRegistrationPassword(chatId: number, password: string): Promise<void> {
|
||||||
|
if (password.length < 6) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Password must be at least 6 characters. Please try again:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionService.setSessionStep(chatId, 'REGISTER_CONFIRM_PASSWORD', { password });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Password saved!\n\n' +
|
||||||
|
'Please confirm your password:'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRegistrationConfirmPassword(chatId: number, confirmPassword: string): Promise<void> {
|
||||||
|
console.log(`🔐 User ${chatId} confirming password`);
|
||||||
|
|
||||||
|
const session = this.sessionService.getSession(chatId);
|
||||||
|
const { phone, name, email, password } = session.data;
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
console.log(`❌ User ${chatId} password confirmation failed`);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Passwords don\'t match. Please confirm your password again:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
password,
|
||||||
|
role: 'AGENT' as const // Backend now accepts role field
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📝 User ${chatId} starting registration with data:`, { ...userData, password: '[HIDDEN]' });
|
||||||
|
|
||||||
|
const result = await this.authService.register(userData);
|
||||||
|
|
||||||
|
if (result.success && result.user) {
|
||||||
|
console.log(`✅ User ${chatId} registration successful, assigned role: ${result.user.role}`);
|
||||||
|
|
||||||
|
// Verify the registered user has AGENT role
|
||||||
|
if (result.user.role !== 'AGENT') {
|
||||||
|
console.log(`❌ User ${chatId} registration failed - role is ${result.user.role}, not AGENT`);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Registration failed: Invalid role assignment.\n\n' +
|
||||||
|
'Please contact support for assistance.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User has AGENT role - proceed with login
|
||||||
|
this.sessionService.updateSession(chatId, {
|
||||||
|
step: 'LOGGED_IN',
|
||||||
|
user: result.user
|
||||||
|
});
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'🎉 AGENT registration successful! Welcome to Yaltopia!'
|
||||||
|
);
|
||||||
|
await this.showMainMenu(chatId, result.user.name);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ User ${chatId} registration failed: ${result.message}`);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`❌ Registration failed: ${result.message || 'Unknown error'}\n\n` +
|
||||||
|
'Please try again by typing /start'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showMainMenu(chatId: number, userName: string): Promise<void> {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`🏠 Welcome ${userName}!\n\n` +
|
||||||
|
'What would you like to do?',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [
|
||||||
|
[{ text: '🏘️ Browse Properties' }, { text: '🏠 My Properties' }],
|
||||||
|
[{ text: '➕ Add Property' }, { text: '👤 Profile' }],
|
||||||
|
[{ text: '🚪 Logout' }]
|
||||||
|
],
|
||||||
|
resize_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLogout(chatId: number): Promise<void> {
|
||||||
|
// Clear access token
|
||||||
|
this.apiService.clearAccessToken();
|
||||||
|
|
||||||
|
this.sessionService.clearSession(chatId);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'👋 You have been logged out successfully!\n\n' +
|
||||||
|
'Type /start to login again.',
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/features/auth/auth.service.ts
Normal file
181
src/features/auth/auth.service.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { ApiService } from '../../shared/services/api.service';
|
||||||
|
import { User, UserRegistration } from '../../shared/types';
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
constructor(private apiService: ApiService) {}
|
||||||
|
|
||||||
|
async checkPhoneExists(phone: string): Promise<{ exists: boolean; isAgent: boolean; userRole?: string; message?: string }> {
|
||||||
|
console.log(`🔍 Checking phone existence for: ${phone}`);
|
||||||
|
|
||||||
|
const response = await this.apiService.get(`/telegram-auth/phone/${phone}/check`);
|
||||||
|
|
||||||
|
console.log('📞 Phone check API response:', JSON.stringify(response, null, 2));
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
console.log(`❌ Phone check failed: ${response.message}`);
|
||||||
|
return { exists: false, isAgent: false, message: response.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the actual API response structure
|
||||||
|
const apiData = response.data as any;
|
||||||
|
|
||||||
|
if (!apiData) {
|
||||||
|
console.log('📞 No data returned from phone check API');
|
||||||
|
return { exists: false, isAgent: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists based on the API response
|
||||||
|
// The API uses "flow" to indicate user status: "login" = existing user, "register" = new user
|
||||||
|
const flow = apiData.flow;
|
||||||
|
const hasAccount = flow === 'login';
|
||||||
|
|
||||||
|
console.log(`📞 Phone check result - flow: ${flow}, hasAccount: ${hasAccount}`);
|
||||||
|
|
||||||
|
if (!hasAccount) {
|
||||||
|
console.log('👤 New user detected - directing to registration');
|
||||||
|
return { exists: false, isAgent: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// User exists - we'll check their role after login since the phone check doesn't provide role info
|
||||||
|
// and the user details endpoint may not be available
|
||||||
|
console.log('👤 Existing user detected - will verify role after login');
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
isAgent: true, // Assume true for now, will verify after login
|
||||||
|
userRole: undefined, // Will be checked after login
|
||||||
|
message: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByPhone(phone: string): Promise<{ success: boolean; user?: User; message?: string }> {
|
||||||
|
const response = await this.apiService.get<User>(`/telegram-auth/user/${phone}`);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
user: response.data,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateEmail(email: string): Promise<boolean> {
|
||||||
|
console.log(`📧 Validating email: ${email}`);
|
||||||
|
const response = await this.apiService.get(`/telegram-auth/validate-email/${email}`);
|
||||||
|
console.log(`📧 Email validation result: ${response.success}`);
|
||||||
|
return response.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(userData: UserRegistration): Promise<{ success: boolean; user?: User; message?: string }> {
|
||||||
|
console.log('📝 Starting user registration:', { ...userData, password: '[HIDDEN]' });
|
||||||
|
|
||||||
|
const response = await this.apiService.post<User>('/telegram-auth/telegram-register', userData);
|
||||||
|
|
||||||
|
console.log('📝 Registration API full response:', JSON.stringify(response, null, 2));
|
||||||
|
|
||||||
|
// Extract user data and access token from the nested response structure
|
||||||
|
let extractedUser = response.data;
|
||||||
|
let accessToken: string | undefined;
|
||||||
|
|
||||||
|
// The API returns nested structure: response.data.user and response.data.access_token
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const responseAny = response as any;
|
||||||
|
|
||||||
|
if (responseAny.data?.user) {
|
||||||
|
extractedUser = responseAny.data.user;
|
||||||
|
accessToken = responseAny.data.access_token;
|
||||||
|
console.log('✅ Found user data in response.data.user');
|
||||||
|
} else if (responseAny.userData?.user) {
|
||||||
|
extractedUser = responseAny.userData.user;
|
||||||
|
accessToken = responseAny.userData.access_token;
|
||||||
|
console.log('✅ Found user data in response.userData.user');
|
||||||
|
} else if (responseAny.user) {
|
||||||
|
extractedUser = responseAny.user;
|
||||||
|
accessToken = responseAny.access_token;
|
||||||
|
console.log('✅ Found user data in response.user');
|
||||||
|
} else {
|
||||||
|
console.log('🔍 User data structure:', responseAny.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the access token for future API requests
|
||||||
|
if (accessToken) {
|
||||||
|
this.apiService.setAccessToken(accessToken);
|
||||||
|
// Add token to user object for session storage
|
||||||
|
if (extractedUser) {
|
||||||
|
extractedUser.access_token = accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📝 Final extracted user:', extractedUser ? { ...extractedUser, password: undefined, access_token: '[HIDDEN]' } : 'No user data');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
user: extractedUser,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(phone: string, password: string): Promise<{ success: boolean; user?: User; message?: string }> {
|
||||||
|
console.log(`🔐 Attempting login for phone: ${phone}`);
|
||||||
|
|
||||||
|
const response = await this.apiService.post<User>('/telegram-auth/telegram-login', {
|
||||||
|
phone,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔐 Login API full response:', JSON.stringify(response, null, 2));
|
||||||
|
|
||||||
|
// Extract user data and access token from the nested response structure
|
||||||
|
let userData = response.data;
|
||||||
|
let accessToken: string | undefined;
|
||||||
|
|
||||||
|
// Check if the response structure is nested
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const responseAny = response as any;
|
||||||
|
|
||||||
|
console.log('🔍 [DEBUG] Raw response.data:', JSON.stringify(responseAny.data, null, 2));
|
||||||
|
|
||||||
|
if (responseAny.data?.user) {
|
||||||
|
userData = responseAny.data.user;
|
||||||
|
accessToken = responseAny.data.access_token;
|
||||||
|
console.log('✅ Found user data in response.data.user');
|
||||||
|
console.log('🔍 [DEBUG] Extracted user data:', JSON.stringify(userData, null, 2));
|
||||||
|
} else if (responseAny.userData?.user) {
|
||||||
|
userData = responseAny.userData.user;
|
||||||
|
accessToken = responseAny.userData.access_token;
|
||||||
|
console.log('✅ Found user data in response.userData.user');
|
||||||
|
console.log('🔍 [DEBUG] Extracted user data:', JSON.stringify(userData, null, 2));
|
||||||
|
} else if (responseAny.user) {
|
||||||
|
userData = responseAny.user;
|
||||||
|
accessToken = responseAny.access_token;
|
||||||
|
console.log('✅ Found user data in response.user');
|
||||||
|
console.log('🔍 [DEBUG] Extracted user data:', JSON.stringify(userData, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log('🔍 Login response structure:', responseAny.data);
|
||||||
|
// Try to use response.data directly if it has user fields
|
||||||
|
if (responseAny.data && (responseAny.data.name || responseAny.data.email || responseAny.data.phone)) {
|
||||||
|
userData = responseAny.data;
|
||||||
|
accessToken = responseAny.data.access_token || responseAny.access_token;
|
||||||
|
console.log('✅ Using response.data directly as user data');
|
||||||
|
console.log('🔍 [DEBUG] Direct user data:', JSON.stringify(userData, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the access token for future API requests
|
||||||
|
if (accessToken) {
|
||||||
|
this.apiService.setAccessToken(accessToken);
|
||||||
|
// Add token to user object for session storage
|
||||||
|
if (userData) {
|
||||||
|
userData.access_token = accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔐 Final user data:', userData ? { ...userData, password: undefined, access_token: '[HIDDEN]' } : 'No user data');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
user: userData,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
811
src/features/properties/properties.handler.ts
Normal file
811
src/features/properties/properties.handler.ts
Normal file
|
|
@ -0,0 +1,811 @@
|
||||||
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import { PropertiesService } from './properties.service';
|
||||||
|
import { SessionService } from '../../shared/services/session.service';
|
||||||
|
import { Property } from '../../shared/types';
|
||||||
|
|
||||||
|
export class PropertiesHandler {
|
||||||
|
constructor(
|
||||||
|
private bot: TelegramBot,
|
||||||
|
private propertiesService: PropertiesService,
|
||||||
|
private sessionService: SessionService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleBrowseProperties(chatId: number): Promise<void> {
|
||||||
|
const result = await this.propertiesService.getProperties();
|
||||||
|
|
||||||
|
if (!result.success || !result.properties) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Failed to load properties. Please try again later.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.properties.length === 0) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'📭 No properties available at the moment.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`🏘️ Found ${result.properties.length} properties:`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const property of result.properties.slice(0, 10)) { // Show first 10
|
||||||
|
await this.sendPropertyCard(chatId, property);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.properties.length > 10) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`... and ${result.properties.length - 10} more properties.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleMyProperties(chatId: number): Promise<void> {
|
||||||
|
const session = this.sessionService.getSession(chatId);
|
||||||
|
|
||||||
|
if (!session.user) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Please login first.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.propertiesService.getUserProperties(session.user.id);
|
||||||
|
|
||||||
|
if (!result.success || !result.properties) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Failed to load your properties. Please try again later.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.properties.length === 0) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'📭 You haven\'t added any properties yet.\n\n' +
|
||||||
|
'Use "➕ Add Property" to create your first listing!'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`🏠 Your Properties (${result.properties.length}):`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const property of result.properties) {
|
||||||
|
await this.sendPropertyCard(chatId, property, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAddProperty(chatId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_TITLE');
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'➕ Let\'s add a new property!\n\n' +
|
||||||
|
'Please enter the property title:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertyTitle(chatId: number, title: string): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_DESCRIPTION', { title });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Title saved!\n\n' +
|
||||||
|
'Please enter a description for the property:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertyDescription(chatId: number, description: string): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_TYPE', { description });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Description saved!\n\n' +
|
||||||
|
'Is this property for rent or sale?',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [
|
||||||
|
[{ text: '🏠 For Rent' }, { text: '💰 For Sale' }],
|
||||||
|
[{ text: '❌ Cancel' }]
|
||||||
|
],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertyType(chatId: number, typeText: string): Promise<void> {
|
||||||
|
console.log(`🔍 [DEBUG] handlePropertyType called with: "${typeText}"`);
|
||||||
|
let type: string;
|
||||||
|
|
||||||
|
if (typeText === '🏠 For Rent') {
|
||||||
|
console.log(`✅ [DEBUG] Valid input: For Rent`);
|
||||||
|
type = 'RENT';
|
||||||
|
} else if (typeText === '💰 For Sale') {
|
||||||
|
console.log(`✅ [DEBUG] Valid input: For Sale`);
|
||||||
|
type = 'SELL';
|
||||||
|
} else {
|
||||||
|
console.log(`❌ [DEBUG] Invalid input: "${typeText}" - showing error message`);
|
||||||
|
// Invalid input - ask user to select from the buttons
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Please select either "🏠 For Rent" or "💰 For Sale" using the buttons below:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [
|
||||||
|
[{ text: '🏠 For Rent' }, { text: '💰 For Sale' }],
|
||||||
|
[{ text: '❌ Cancel' }]
|
||||||
|
],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ [DEBUG] Setting type to: ${type}`);
|
||||||
|
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_PRICE', { type });
|
||||||
|
|
||||||
|
const priceLabel = type === 'RENT' ? 'monthly rent' : 'sale price';
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`✅ Type saved!\n\n` +
|
||||||
|
`Please enter the ${priceLabel} (in ETB):`,
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertyPrice(chatId: number, priceText: string): Promise<void> {
|
||||||
|
const price = parseFloat(priceText);
|
||||||
|
if (isNaN(price) || price <= 0) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Invalid price. Please enter a valid number:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_AREA', { price });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Price saved!\n\n' +
|
||||||
|
'Please enter the area in square meters:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertyArea(chatId: number, areaText: string): Promise<void> {
|
||||||
|
const area = parseFloat(areaText);
|
||||||
|
if (isNaN(area) || area <= 0) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Invalid area. Please enter a valid number:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_ROOMS', { area });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Area saved!\n\n' +
|
||||||
|
'How many rooms does the property have?',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertyRooms(chatId: number, roomsText: string): Promise<void> {
|
||||||
|
const rooms = parseInt(roomsText);
|
||||||
|
if (isNaN(rooms) || rooms <= 0) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Invalid number of rooms. Please enter a valid number:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_TOILETS', { rooms });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Rooms saved!\n\n' +
|
||||||
|
'How many toilets/bathrooms does the property have?',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertyToilets(chatId: number, toiletsText: string): Promise<void> {
|
||||||
|
const toilets = parseInt(toiletsText);
|
||||||
|
if (isNaN(toilets) || toilets <= 0) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Invalid number of toilets. Please enter a valid number:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_SUBCITY', { toilets });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Toilets saved!\n\n' +
|
||||||
|
'Which subcity is the property located in? (e.g., Bole, Kirkos, etc.)',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertySubcity(chatId: number, subcity: string): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_HOUSE_TYPE', { subcity });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Subcity saved!\n\n' +
|
||||||
|
'What type of house is it?',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [
|
||||||
|
[{ text: '🏢 Apartment' }, { text: '🏠 Villa' }],
|
||||||
|
[{ text: '🏘️ Condominium' }, { text: '🏪 Commercial' }],
|
||||||
|
[{ text: '❌ Cancel' }]
|
||||||
|
],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertyHouseType(chatId: number, houseType: string): Promise<void> {
|
||||||
|
let cleanHouseType: string;
|
||||||
|
|
||||||
|
// Only accept valid house type options
|
||||||
|
if (houseType === '🏢 Apartment') {
|
||||||
|
cleanHouseType = 'Apartment';
|
||||||
|
} else if (houseType === '🏠 Villa') {
|
||||||
|
cleanHouseType = 'Villa';
|
||||||
|
} else if (houseType === '🏘️ Condominium') {
|
||||||
|
cleanHouseType = 'Condominium';
|
||||||
|
} else if (houseType === '🏪 Commercial') {
|
||||||
|
cleanHouseType = 'Commercial';
|
||||||
|
} else {
|
||||||
|
// Invalid input - ask user to select from the buttons
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Please select a house type using the buttons below:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [
|
||||||
|
[{ text: '🏢 Apartment' }, { text: '🏠 Villa' }],
|
||||||
|
[{ text: '🏘️ Condominium' }, { text: '🏪 Commercial' }],
|
||||||
|
[{ text: '❌ Cancel' }]
|
||||||
|
],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.sessionService.getSession(chatId);
|
||||||
|
const propertyData = session.data;
|
||||||
|
|
||||||
|
const property: Omit<Property, 'id'> = {
|
||||||
|
title: propertyData.title,
|
||||||
|
description: propertyData.description,
|
||||||
|
type: propertyData.type,
|
||||||
|
price: propertyData.price,
|
||||||
|
area: propertyData.area,
|
||||||
|
rooms: propertyData.rooms,
|
||||||
|
toilets: propertyData.toilets,
|
||||||
|
subcity: propertyData.subcity,
|
||||||
|
houseType: cleanHouseType,
|
||||||
|
status: 'DRAFT',
|
||||||
|
taxRate: 15,
|
||||||
|
isTaxable: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.propertiesService.createProperty(property);
|
||||||
|
|
||||||
|
if (result.success && result.property) {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'LOGGED_IN');
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'🎉 Property added successfully!',
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
await this.sendPropertyCard(chatId, result.property, true);
|
||||||
|
await this.showMainMenu(chatId);
|
||||||
|
} else {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`❌ Failed to add property: ${result.message || 'Unknown error'}`,
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
await this.showMainMenu(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCancelPropertyCreation(chatId: number): Promise<void> {
|
||||||
|
console.log(`❌ User ${chatId} cancelled property creation`);
|
||||||
|
|
||||||
|
// Clear any property creation data and return to main menu
|
||||||
|
this.sessionService.setSessionStep(chatId, 'LOGGED_IN');
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Property creation cancelled.\n\n' +
|
||||||
|
'Returning to main menu...',
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.showMainMenu(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePropertyCallback(callbackQuery: any): Promise<void> {
|
||||||
|
const chatId = callbackQuery.message.chat.id;
|
||||||
|
const data = callbackQuery.data;
|
||||||
|
const messageId = callbackQuery.message.message_id;
|
||||||
|
|
||||||
|
console.log(`🔘 Callback received: ${data} from user ${chatId}`);
|
||||||
|
|
||||||
|
// Answer the callback query to remove loading state
|
||||||
|
await this.bot.answerCallbackQuery(callbackQuery.id);
|
||||||
|
|
||||||
|
if (data.startsWith('edit_property_')) {
|
||||||
|
const propertyId = data.replace('edit_property_', '');
|
||||||
|
await this.handleEditProperty(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('delete_property_')) {
|
||||||
|
const propertyId = data.replace('delete_property_', '');
|
||||||
|
await this.handleDeleteProperty(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('confirm_delete_')) {
|
||||||
|
const propertyId = data.replace('confirm_delete_', '');
|
||||||
|
await this.handleConfirmDelete(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('cancel_delete_')) {
|
||||||
|
const propertyId = data.replace('cancel_delete_', '');
|
||||||
|
await this.handleCancelDelete(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('back_to_property_')) {
|
||||||
|
const propertyId = data.replace('back_to_property_', '');
|
||||||
|
await this.handleBackToProperty(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('edit_title_')) {
|
||||||
|
const propertyId = data.replace('edit_title_', '');
|
||||||
|
await this.handleEditTitle(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('edit_desc_')) {
|
||||||
|
const propertyId = data.replace('edit_desc_', '');
|
||||||
|
await this.handleEditDescription(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('edit_price_')) {
|
||||||
|
const propertyId = data.replace('edit_price_', '');
|
||||||
|
await this.handleEditPrice(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('edit_area_')) {
|
||||||
|
const propertyId = data.replace('edit_area_', '');
|
||||||
|
await this.handleEditArea(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('edit_rooms_')) {
|
||||||
|
const propertyId = data.replace('edit_rooms_', '');
|
||||||
|
await this.handleEditRooms(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('edit_toilets_')) {
|
||||||
|
const propertyId = data.replace('edit_toilets_', '');
|
||||||
|
await this.handleEditToilets(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('edit_location_')) {
|
||||||
|
const propertyId = data.replace('edit_location_', '');
|
||||||
|
await this.handleEditLocation(chatId, propertyId, messageId);
|
||||||
|
} else if (data.startsWith('edit_house_type_')) {
|
||||||
|
const propertyId = data.replace('edit_house_type_', '');
|
||||||
|
await this.handleEditHouseType(chatId, propertyId, messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditProperty(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
await this.bot.editMessageReplyMarkup({
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{ text: '📝 Edit Title', callback_data: `edit_title_${propertyId}` },
|
||||||
|
{ text: '📄 Edit Description', callback_data: `edit_desc_${propertyId}` }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ text: '💰 Edit Price', callback_data: `edit_price_${propertyId}` },
|
||||||
|
{ text: '📐 Edit Area', callback_data: `edit_area_${propertyId}` }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ text: '🛏️ Edit Rooms', callback_data: `edit_rooms_${propertyId}` },
|
||||||
|
{ text: '🚿 Edit Toilets', callback_data: `edit_toilets_${propertyId}` }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ text: '📍 Edit Location', callback_data: `edit_location_${propertyId}` },
|
||||||
|
{ text: '🏠 Edit House Type', callback_data: `edit_house_type_${propertyId}` }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ text: '⬅️ Back', callback_data: `back_to_property_${propertyId}` }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}, { chat_id: chatId, message_id: messageId });
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✏️ Select what you want to edit:\n\n' +
|
||||||
|
'💡 Tip: Click on the option you want to modify.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeleteProperty(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
await this.bot.editMessageReplyMarkup({
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{ text: '⚠️ Yes, Delete', callback_data: `confirm_delete_${propertyId}` },
|
||||||
|
{ text: '❌ Cancel', callback_data: `cancel_delete_${propertyId}` }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}, { chat_id: chatId, message_id: messageId });
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'⚠️ Are you sure you want to delete this property?\n\n' +
|
||||||
|
'🚨 This action cannot be undone!'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConfirmDelete(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await this.propertiesService.deleteProperty(propertyId);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'✅ Property deleted successfully!'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete the property message
|
||||||
|
await this.bot.deleteMessage(chatId, messageId);
|
||||||
|
} else {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`❌ Failed to delete property: ${result.message || 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting property:', error);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ An error occurred while deleting the property.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCancelDelete(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
// Get the property details to restore the original buttons
|
||||||
|
try {
|
||||||
|
const result = await this.propertiesService.getPropertyById(propertyId);
|
||||||
|
|
||||||
|
if (result.success && result.property) {
|
||||||
|
await this.bot.editMessageReplyMarkup({
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{ text: '✏️ Edit', callback_data: `edit_property_${propertyId}` },
|
||||||
|
{ text: '🗑️ Delete', callback_data: `delete_property_${propertyId}` }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}, { chat_id: chatId, message_id: messageId });
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId, '❌ Delete cancelled.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling delete:', error);
|
||||||
|
await this.bot.sendMessage(chatId, '❌ An error occurred.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleBackToProperty(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await this.propertiesService.getPropertyById(propertyId);
|
||||||
|
|
||||||
|
if (result.success && result.property) {
|
||||||
|
await this.bot.editMessageReplyMarkup({
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{ text: '✏️ Edit', callback_data: `edit_property_${propertyId}` },
|
||||||
|
{ text: '🗑️ Delete', callback_data: `delete_property_${propertyId}` }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}, { chat_id: chatId, message_id: messageId });
|
||||||
|
|
||||||
|
await this.bot.sendMessage(chatId, '⬅️ Back to property options.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error going back to property:', error);
|
||||||
|
await this.bot.sendMessage(chatId, '❌ An error occurred.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditTitle(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_TITLE', { propertyId, messageId });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'📝 Enter the new title for the property:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditDescription(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_DESCRIPTION', { propertyId, messageId });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'📄 Enter the new description for the property:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditPrice(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_PRICE', { propertyId, messageId });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'💰 Enter the new price (in ETB):',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditArea(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_AREA', { propertyId, messageId });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'📐 Enter the new area (in square meters):',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditRooms(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_ROOMS', { propertyId, messageId });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'🛏️ Enter the new number of rooms:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditToilets(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_TOILETS', { propertyId, messageId });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'🚿 Enter the new number of toilets:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditLocation(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_LOCATION', { propertyId, messageId });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'📍 Enter the new location (subcity):',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditHouseType(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_HOUSE_TYPE', { propertyId, messageId });
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'🏠 Select the new house type:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [
|
||||||
|
[{ text: '🏢 Apartment' }, { text: '🏠 Villa' }],
|
||||||
|
[{ text: '🏘️ Condominium' }, { text: '🏪 Commercial' }],
|
||||||
|
[{ text: '❌ Cancel Edit' }]
|
||||||
|
],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditPropertyValue(chatId: number, field: string, value: string): Promise<void> {
|
||||||
|
const session = this.sessionService.getSession(chatId);
|
||||||
|
const { propertyId, messageId } = session.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let updateData: any = {};
|
||||||
|
let processedValue: any = value;
|
||||||
|
|
||||||
|
// Process the value based on the field type
|
||||||
|
switch (field) {
|
||||||
|
case 'title':
|
||||||
|
case 'description':
|
||||||
|
case 'subcity':
|
||||||
|
updateData[field] = value;
|
||||||
|
break;
|
||||||
|
case 'price':
|
||||||
|
case 'area':
|
||||||
|
processedValue = parseFloat(value);
|
||||||
|
if (isNaN(processedValue) || processedValue <= 0) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Invalid number. Please enter a valid positive number:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateData[field] = processedValue;
|
||||||
|
break;
|
||||||
|
case 'rooms':
|
||||||
|
case 'toilets':
|
||||||
|
processedValue = parseInt(value);
|
||||||
|
if (isNaN(processedValue) || processedValue <= 0) {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Invalid number. Please enter a valid positive integer:'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateData[field] = processedValue;
|
||||||
|
break;
|
||||||
|
case 'houseType':
|
||||||
|
// Validate house type
|
||||||
|
if (value === '🏢 Apartment') {
|
||||||
|
updateData.houseType = 'Apartment';
|
||||||
|
} else if (value === '🏠 Villa') {
|
||||||
|
updateData.houseType = 'Villa';
|
||||||
|
} else if (value === '🏘️ Condominium') {
|
||||||
|
updateData.houseType = 'Condominium';
|
||||||
|
} else if (value === '🏪 Commercial') {
|
||||||
|
updateData.houseType = 'Commercial';
|
||||||
|
} else {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Please select a house type using the buttons below:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [
|
||||||
|
[{ text: '🏢 Apartment' }, { text: '🏠 Villa' }],
|
||||||
|
[{ text: '🏘️ Condominium' }, { text: '🏪 Commercial' }],
|
||||||
|
[{ text: '❌ Cancel Edit' }]
|
||||||
|
],
|
||||||
|
resize_keyboard: true,
|
||||||
|
one_time_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the property
|
||||||
|
const result = await this.propertiesService.updateProperty(propertyId, updateData);
|
||||||
|
|
||||||
|
if (result.success && result.property) {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'LOGGED_IN');
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`✅ Property ${field} updated successfully!`,
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show the updated property
|
||||||
|
await this.sendPropertyCard(chatId, result.property, true);
|
||||||
|
await this.showMainMenu(chatId);
|
||||||
|
} else {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
`❌ Failed to update property: ${result.message || 'Unknown error'}`,
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
await this.showMainMenu(chatId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating property:', error);
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ An error occurred while updating the property.',
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
await this.showMainMenu(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCancelEdit(chatId: number): Promise<void> {
|
||||||
|
this.sessionService.setSessionStep(chatId, 'LOGGED_IN');
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'❌ Edit cancelled.',
|
||||||
|
{ reply_markup: { remove_keyboard: true } }
|
||||||
|
);
|
||||||
|
await this.showMainMenu(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendPropertyCard(chatId: number, property: Property, isOwner = false): Promise<void> {
|
||||||
|
console.log(`🔍 [DEBUG] sendPropertyCard - isOwner: ${isOwner}, property.id: ${property.id}`);
|
||||||
|
console.log(`🔍 [DEBUG] Property object:`, JSON.stringify(property, null, 2));
|
||||||
|
|
||||||
|
const typeEmoji = property.type === 'RENT' ? '🏠' : '💰';
|
||||||
|
const statusEmoji = property.status === 'PUBLISHED' ? '✅' : '📝';
|
||||||
|
|
||||||
|
const message =
|
||||||
|
`${typeEmoji} ${property.title}\n` +
|
||||||
|
`${statusEmoji} Status: ${property.status}\n\n` +
|
||||||
|
`📝 ${property.description}\n\n` +
|
||||||
|
`💵 Price: ${property.price.toLocaleString()} ETB ${property.type === 'RENT' ? '/month' : ''}\n` +
|
||||||
|
`📐 Area: ${property.area} m²\n` +
|
||||||
|
`🛏️ Rooms: ${property.rooms}\n` +
|
||||||
|
`🚿 Toilets: ${property.toilets}\n` +
|
||||||
|
`📍 Location: ${property.subcity}\n` +
|
||||||
|
`🏠 Type: ${property.houseType}`;
|
||||||
|
|
||||||
|
if (isOwner && property.id) {
|
||||||
|
console.log(`✅ [DEBUG] Adding inline buttons for property ${property.id}`);
|
||||||
|
// Add action buttons for property owner (removed publish button)
|
||||||
|
await this.bot.sendMessage(chatId, message, {
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{ text: '✏️ Edit', callback_data: `edit_property_${property.id}` },
|
||||||
|
{ text: '🗑️ Delete', callback_data: `delete_property_${property.id}` }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`❌ [DEBUG] No buttons - isOwner: ${isOwner}, property.id: ${property.id}`);
|
||||||
|
await this.bot.sendMessage(chatId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showMainMenu(chatId: number): Promise<void> {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'What would you like to do next?',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
keyboard: [
|
||||||
|
[{ text: '🏘️ Browse Properties' }, { text: '🏠 My Properties' }],
|
||||||
|
[{ text: '➕ Add Property' }, { text: '👤 Profile' }],
|
||||||
|
[{ text: '🚪 Logout' }]
|
||||||
|
],
|
||||||
|
resize_keyboard: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/features/properties/properties.service.ts
Normal file
78
src/features/properties/properties.service.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { ApiService } from '../../shared/services/api.service';
|
||||||
|
import { Property } from '../../shared/types';
|
||||||
|
|
||||||
|
export class PropertiesService {
|
||||||
|
constructor(private apiService: ApiService) {}
|
||||||
|
|
||||||
|
async getProperties(): Promise<{ success: boolean; properties?: Property[]; message?: string }> {
|
||||||
|
const response = await this.apiService.get<Property[]>('/listings');
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
properties: response.data,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserProperties(userId: string): Promise<{ success: boolean; properties?: Property[]; message?: string }> {
|
||||||
|
// Use the correct API endpoint for user's properties
|
||||||
|
const response = await this.apiService.get<Property[]>('/listings/my-listings');
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
properties: response.data,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProperty(property: Omit<Property, 'id'>): Promise<{ success: boolean; property?: Property; message?: string }> {
|
||||||
|
const response = await this.apiService.post<Property>('/listings', property);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
property: response.data,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPropertyById(id: string): Promise<{ success: boolean; property?: Property; message?: string }> {
|
||||||
|
const response = await this.apiService.get<Property>(`/listings/${id}`);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
property: response.data,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProperty(id: string, property: Partial<Property>): Promise<{ success: boolean; property?: Property; message?: string }> {
|
||||||
|
const response = await this.apiService.patch<Property>(`/listings/${id}`, property);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
property: response.data,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProperty(id: string): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const response = await this.apiService.delete(`/listings/${id}`);
|
||||||
|
return {
|
||||||
|
success: response.success,
|
||||||
|
message: response.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async togglePropertyStatus(id: string): Promise<{ success: boolean; property?: Property; message?: string }> {
|
||||||
|
// First get the current property to know its current status
|
||||||
|
const currentProperty = await this.getPropertyById(id);
|
||||||
|
|
||||||
|
if (!currentProperty.success || !currentProperty.property) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Property not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the status
|
||||||
|
const newStatus = currentProperty.property.status === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED';
|
||||||
|
|
||||||
|
// Update the property with new status
|
||||||
|
return await this.updateProperty(id, { status: newStatus });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/index.ts
Normal file
30
src/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { YaltopiaBot } from './bot/bot';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000/api';
|
||||||
|
|
||||||
|
if (!TELEGRAM_BOT_TOKEN) {
|
||||||
|
console.error('❌ TELEGRAM_BOT_TOKEN is required in environment variables');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and start the bot
|
||||||
|
const bot = new YaltopiaBot(TELEGRAM_BOT_TOKEN, API_BASE_URL);
|
||||||
|
bot.start();
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\n🛑 Shutting down bot...');
|
||||||
|
bot.stop();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('\n🛑 Shutting down bot...');
|
||||||
|
bot.stop();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
287
src/shared/monitoring/admin-notifier.ts
Normal file
287
src/shared/monitoring/admin-notifier.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export interface AdminConfig {
|
||||||
|
adminChatIds: number[];
|
||||||
|
webhookUrl?: string;
|
||||||
|
emailConfig?: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
to: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminNotifier {
|
||||||
|
private bot: TelegramBot;
|
||||||
|
private config: AdminConfig;
|
||||||
|
|
||||||
|
constructor(bot: TelegramBot, config: AdminConfig) {
|
||||||
|
this.bot = bot;
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendAlert(level: 'critical' | 'warning' | 'info', title: string, message: string, data?: any): Promise<void> {
|
||||||
|
const emoji = this.getEmojiForLevel(level);
|
||||||
|
const formattedMessage = this.formatMessage(level, title, message, data);
|
||||||
|
|
||||||
|
// Send to admin chat(s)
|
||||||
|
for (const adminChatId of this.config.adminChatIds) {
|
||||||
|
try {
|
||||||
|
await this.bot.sendMessage(adminChatId, formattedMessage, {
|
||||||
|
parse_mode: 'Markdown',
|
||||||
|
disable_web_page_preview: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to send alert to admin ${adminChatId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to webhook if configured
|
||||||
|
if (this.config.webhookUrl) {
|
||||||
|
await this.sendWebhookAlert(level, title, message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email if configured (would need email library)
|
||||||
|
if (this.config.emailConfig) {
|
||||||
|
await this.sendEmailAlert(level, title, message, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSystemReport(report: string): Promise<void> {
|
||||||
|
const chunks = this.splitMessage(report, 4000); // Telegram message limit
|
||||||
|
|
||||||
|
for (const adminChatId of this.config.adminChatIds) {
|
||||||
|
try {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await this.bot.sendMessage(adminChatId, chunk, {
|
||||||
|
parse_mode: 'Markdown',
|
||||||
|
disable_web_page_preview: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to send report to admin ${adminChatId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendErrorSummary(errors: Array<{ timestamp: Date; error: string; userId?: number; context?: string }>): Promise<void> {
|
||||||
|
if (errors.length === 0) return;
|
||||||
|
|
||||||
|
const summary = this.formatErrorSummary(errors);
|
||||||
|
await this.sendAlert('warning', 'Error Summary', summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendUserActivityAlert(userId: number, activity: string, suspicious: boolean = false): Promise<void> {
|
||||||
|
const level = suspicious ? 'warning' : 'info';
|
||||||
|
const title = suspicious ? 'Suspicious User Activity' : 'User Activity Alert';
|
||||||
|
const message = `User ${userId} performed: ${activity}`;
|
||||||
|
|
||||||
|
await this.sendAlert(level, title, message, { userId, activity, suspicious });
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSystemHealthAlert(status: 'healthy' | 'warning' | 'critical', issues: string[]): Promise<void> {
|
||||||
|
if (status === 'healthy') return;
|
||||||
|
|
||||||
|
const level = status === 'critical' ? 'critical' : 'warning';
|
||||||
|
const title = `System Health: ${status.toUpperCase()}`;
|
||||||
|
const message = issues.length > 0 ? issues.join('\n') : 'System health check completed';
|
||||||
|
|
||||||
|
await this.sendAlert(level, title, message, { status, issues });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmojiForLevel(level: string): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'critical': return '🚨';
|
||||||
|
case 'warning': return '⚠️';
|
||||||
|
case 'info': return 'ℹ️';
|
||||||
|
default: return '📢';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatMessage(level: string, title: string, message: string, data?: any): string {
|
||||||
|
const emoji = this.getEmojiForLevel(level);
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
let formatted = `${emoji} **${title}**\n\n`;
|
||||||
|
formatted += `📅 ${timestamp}\n`;
|
||||||
|
formatted += `🔍 Level: ${level.toUpperCase()}\n\n`;
|
||||||
|
formatted += `📝 ${message}\n`;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
formatted += `\n📊 **Details:**\n`;
|
||||||
|
formatted += '```json\n';
|
||||||
|
formatted += JSON.stringify(data, null, 2);
|
||||||
|
formatted += '\n```';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatErrorSummary(errors: Array<{ timestamp: Date; error: string; userId?: number; context?: string }>): string {
|
||||||
|
const recentErrors = errors.slice(0, 10); // Show last 10 errors
|
||||||
|
|
||||||
|
let summary = `📊 **Error Summary** (${errors.length} total errors)\n\n`;
|
||||||
|
|
||||||
|
for (const error of recentErrors) {
|
||||||
|
summary += `🕐 ${error.timestamp.toISOString()}\n`;
|
||||||
|
summary += `👤 User: ${error.userId || 'System'}\n`;
|
||||||
|
summary += `❌ ${error.error}\n`;
|
||||||
|
if (error.context) {
|
||||||
|
summary += `📝 Context: ${error.context}\n`;
|
||||||
|
}
|
||||||
|
summary += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 10) {
|
||||||
|
summary += `... and ${errors.length - 10} more errors\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private splitMessage(message: string, maxLength: number): string[] {
|
||||||
|
if (message.length <= maxLength) {
|
||||||
|
return [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let currentChunk = '';
|
||||||
|
|
||||||
|
const lines = message.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (currentChunk.length + line.length + 1 > maxLength) {
|
||||||
|
if (currentChunk) {
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
currentChunk = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If single line is too long, split it
|
||||||
|
if (line.length > maxLength) {
|
||||||
|
const words = line.split(' ');
|
||||||
|
let currentLine = '';
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
if (currentLine.length + word.length + 1 > maxLength) {
|
||||||
|
if (currentLine) {
|
||||||
|
chunks.push(currentLine);
|
||||||
|
currentLine = '';
|
||||||
|
}
|
||||||
|
currentLine = word;
|
||||||
|
} else {
|
||||||
|
currentLine += (currentLine ? ' ' : '') + word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine) {
|
||||||
|
currentChunk = currentLine;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentChunk = line;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentChunk += (currentChunk ? '\n' : '') + line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChunk) {
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendWebhookAlert(level: string, title: string, message: string, data?: any): Promise<void> {
|
||||||
|
if (!this.config.webhookUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
level,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
bot: 'Yaltopia Bot'
|
||||||
|
};
|
||||||
|
|
||||||
|
await axios.post(this.config.webhookUrl, payload, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send webhook alert:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendEmailAlert(level: string, title: string, message: string, data?: any): Promise<void> {
|
||||||
|
// Email implementation would go here
|
||||||
|
// You'd need to install nodemailer: npm install nodemailer @types/nodemailer
|
||||||
|
console.log('Email alert would be sent here:', { level, title, message, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Commands Handler
|
||||||
|
async handleAdminCommand(chatId: number, command: string, args: string[]): Promise<void> {
|
||||||
|
if (!this.config.adminChatIds.includes(chatId)) {
|
||||||
|
await this.bot.sendMessage(chatId, '❌ Unauthorized access');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case '/status':
|
||||||
|
await this.handleStatusCommand(chatId);
|
||||||
|
break;
|
||||||
|
case '/metrics':
|
||||||
|
await this.handleMetricsCommand(chatId);
|
||||||
|
break;
|
||||||
|
case '/errors':
|
||||||
|
await this.handleErrorsCommand(chatId, args);
|
||||||
|
break;
|
||||||
|
case '/users':
|
||||||
|
await this.handleUsersCommand(chatId, args);
|
||||||
|
break;
|
||||||
|
case '/restart':
|
||||||
|
await this.handleRestartCommand(chatId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'🤖 **Admin Commands:**\n\n' +
|
||||||
|
'/status - System health status\n' +
|
||||||
|
'/metrics - Current metrics\n' +
|
||||||
|
'/errors [count] - Recent errors\n' +
|
||||||
|
'/users [count] - Recent user activity\n' +
|
||||||
|
'/restart - Restart bot (with confirmation)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStatusCommand(chatId: number): Promise<void> {
|
||||||
|
// This would integrate with SystemMonitor
|
||||||
|
await this.bot.sendMessage(chatId, '✅ System status check would be displayed here');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMetricsCommand(chatId: number): Promise<void> {
|
||||||
|
// This would integrate with SystemMonitor
|
||||||
|
await this.bot.sendMessage(chatId, '📊 System metrics would be displayed here');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleErrorsCommand(chatId: number, args: string[]): Promise<void> {
|
||||||
|
const count = args[0] ? parseInt(args[0]) : 10;
|
||||||
|
await this.bot.sendMessage(chatId, `📋 Last ${count} errors would be displayed here`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUsersCommand(chatId: number, args: string[]): Promise<void> {
|
||||||
|
const count = args[0] ? parseInt(args[0]) : 20;
|
||||||
|
await this.bot.sendMessage(chatId, `👥 Last ${count} user activities would be displayed here`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRestartCommand(chatId: number): Promise<void> {
|
||||||
|
await this.bot.sendMessage(chatId,
|
||||||
|
'⚠️ **Restart Confirmation Required**\n\n' +
|
||||||
|
'Are you sure you want to restart the bot?\n' +
|
||||||
|
'Reply with "CONFIRM RESTART" to proceed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
250
src/shared/monitoring/logger.ts
Normal file
250
src/shared/monitoring/logger.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export enum LogLevel {
|
||||||
|
ERROR = 0,
|
||||||
|
WARN = 1,
|
||||||
|
INFO = 2,
|
||||||
|
DEBUG = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: Date;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
userId?: number;
|
||||||
|
context?: any;
|
||||||
|
stack?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Logger {
|
||||||
|
private logLevel: LogLevel;
|
||||||
|
private logDir: string;
|
||||||
|
private maxFileSize: number;
|
||||||
|
private maxFiles: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
logLevel: LogLevel = LogLevel.INFO,
|
||||||
|
logDir: string = './logs',
|
||||||
|
maxFileSize: number = 10 * 1024 * 1024, // 10MB
|
||||||
|
maxFiles: number = 5
|
||||||
|
) {
|
||||||
|
this.logLevel = logLevel;
|
||||||
|
this.logDir = logDir;
|
||||||
|
this.maxFileSize = maxFileSize;
|
||||||
|
this.maxFiles = maxFiles;
|
||||||
|
|
||||||
|
// Create logs directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(level: LogLevel): boolean {
|
||||||
|
return level <= this.logLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatLogEntry(entry: LogEntry): string {
|
||||||
|
const timestamp = entry.timestamp.toISOString();
|
||||||
|
const level = LogLevel[entry.level];
|
||||||
|
const userId = entry.userId ? `[User:${entry.userId}]` : '';
|
||||||
|
const context = entry.context ? `[${JSON.stringify(entry.context)}]` : '';
|
||||||
|
|
||||||
|
let logLine = `${timestamp} [${level}] ${userId} ${entry.message} ${context}`;
|
||||||
|
|
||||||
|
if (entry.stack) {
|
||||||
|
logLine += `\nStack: ${entry.stack}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return logLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeToFile(entry: LogEntry): void {
|
||||||
|
const filename = `bot-${new Date().toISOString().split('T')[0]}.log`;
|
||||||
|
const filepath = path.join(this.logDir, filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logLine = this.formatLogEntry(entry) + '\n';
|
||||||
|
|
||||||
|
// Check file size and rotate if necessary
|
||||||
|
if (fs.existsSync(filepath)) {
|
||||||
|
const stats = fs.statSync(filepath);
|
||||||
|
if (stats.size > this.maxFileSize) {
|
||||||
|
this.rotateLogFile(filepath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.appendFileSync(filepath, logLine);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to write to log file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rotateLogFile(filepath: string): void {
|
||||||
|
const dir = path.dirname(filepath);
|
||||||
|
const basename = path.basename(filepath, '.log');
|
||||||
|
|
||||||
|
// Rotate existing files
|
||||||
|
for (let i = this.maxFiles - 1; i > 0; i--) {
|
||||||
|
const oldFile = path.join(dir, `${basename}.${i}.log`);
|
||||||
|
const newFile = path.join(dir, `${basename}.${i + 1}.log`);
|
||||||
|
|
||||||
|
if (fs.existsSync(oldFile)) {
|
||||||
|
if (i === this.maxFiles - 1) {
|
||||||
|
fs.unlinkSync(oldFile); // Delete oldest file
|
||||||
|
} else {
|
||||||
|
fs.renameSync(oldFile, newFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move current file to .1
|
||||||
|
const rotatedFile = path.join(dir, `${basename}.1.log`);
|
||||||
|
fs.renameSync(filepath, rotatedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, userId?: number, context?: any, error?: Error): void {
|
||||||
|
if (!this.shouldLog(LogLevel.ERROR)) return;
|
||||||
|
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message,
|
||||||
|
userId,
|
||||||
|
context,
|
||||||
|
stack: error?.stack
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(`🚨 ${this.formatLogEntry(entry)}`);
|
||||||
|
this.writeToFile(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, userId?: number, context?: any): void {
|
||||||
|
if (!this.shouldLog(LogLevel.WARN)) return;
|
||||||
|
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
level: LogLevel.WARN,
|
||||||
|
message,
|
||||||
|
userId,
|
||||||
|
context
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn(`⚠️ ${this.formatLogEntry(entry)}`);
|
||||||
|
this.writeToFile(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, userId?: number, context?: any): void {
|
||||||
|
if (!this.shouldLog(LogLevel.INFO)) return;
|
||||||
|
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
level: LogLevel.INFO,
|
||||||
|
message,
|
||||||
|
userId,
|
||||||
|
context
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`ℹ️ ${this.formatLogEntry(entry)}`);
|
||||||
|
this.writeToFile(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string, userId?: number, context?: any): void {
|
||||||
|
if (!this.shouldLog(LogLevel.DEBUG)) return;
|
||||||
|
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
level: LogLevel.DEBUG,
|
||||||
|
message,
|
||||||
|
userId,
|
||||||
|
context
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`🐛 ${this.formatLogEntry(entry)}`);
|
||||||
|
this.writeToFile(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structured logging methods
|
||||||
|
userAction(userId: number, action: string, success: boolean, details?: any): void {
|
||||||
|
this.info(`User action: ${action}`, userId, { success, ...details });
|
||||||
|
}
|
||||||
|
|
||||||
|
apiCall(method: string, endpoint: string, status: number, duration: number, userId?: number): void {
|
||||||
|
this.info(`API call: ${method} ${endpoint}`, userId, { status, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
securityEvent(event: string, userId?: number, details?: any): void {
|
||||||
|
this.warn(`Security event: ${event}`, userId, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
systemEvent(event: string, details?: any): void {
|
||||||
|
this.info(`System event: ${event}`, undefined, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent logs
|
||||||
|
getRecentLogs(hours: number = 24, level?: LogLevel): LogEntry[] {
|
||||||
|
const logs: LogEntry[] = [];
|
||||||
|
const cutoffTime = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(this.logDir)
|
||||||
|
.filter(f => f.endsWith('.log'))
|
||||||
|
.sort()
|
||||||
|
.reverse(); // Most recent first
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filepath = path.join(this.logDir, file);
|
||||||
|
const content = fs.readFileSync(filepath, 'utf8');
|
||||||
|
const lines = content.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z) \[(\w+)\]/);
|
||||||
|
if (match) {
|
||||||
|
const timestamp = new Date(match[1]);
|
||||||
|
if (timestamp < cutoffTime) continue;
|
||||||
|
|
||||||
|
const logLevel = LogLevel[match[2] as keyof typeof LogLevel];
|
||||||
|
if (level !== undefined && logLevel !== level) continue;
|
||||||
|
|
||||||
|
// Parse the rest of the log entry (simplified)
|
||||||
|
logs.push({
|
||||||
|
timestamp,
|
||||||
|
level: logLevel,
|
||||||
|
message: line.substring(match[0].length).trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
// Skip malformed log lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.error('Failed to read log files', undefined, { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs.slice(0, 1000); // Limit to 1000 entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean old log files
|
||||||
|
cleanOldLogs(daysToKeep: number = 30): void {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(this.logDir);
|
||||||
|
const cutoffTime = Date.now() - daysToKeep * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.log')) continue;
|
||||||
|
|
||||||
|
const filepath = path.join(this.logDir, file);
|
||||||
|
const stats = fs.statSync(filepath);
|
||||||
|
|
||||||
|
if (stats.mtime.getTime() < cutoffTime) {
|
||||||
|
fs.unlinkSync(filepath);
|
||||||
|
this.info(`Cleaned old log file: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.error('Failed to clean old logs', undefined, { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
312
src/shared/monitoring/system-monitor.ts
Normal file
312
src/shared/monitoring/system-monitor.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export interface SystemMetrics {
|
||||||
|
totalUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
totalMessages: number;
|
||||||
|
errorCount: number;
|
||||||
|
apiCalls: number;
|
||||||
|
successfulLogins: number;
|
||||||
|
failedLogins: number;
|
||||||
|
registrations: number;
|
||||||
|
propertiesCreated: number;
|
||||||
|
uptime: number;
|
||||||
|
memoryUsage: NodeJS.MemoryUsage;
|
||||||
|
lastError?: {
|
||||||
|
timestamp: Date;
|
||||||
|
error: string;
|
||||||
|
userId?: number;
|
||||||
|
context?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertConfig {
|
||||||
|
adminChatId?: number;
|
||||||
|
webhookUrl?: string;
|
||||||
|
emailConfig?: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
to: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemMonitor extends EventEmitter {
|
||||||
|
private metrics: SystemMetrics;
|
||||||
|
private startTime: Date;
|
||||||
|
private alertConfig: AlertConfig;
|
||||||
|
private activeUsers = new Set<number>();
|
||||||
|
private recentErrors: Array<{ timestamp: Date; error: string; userId?: number; context?: string }> = [];
|
||||||
|
|
||||||
|
constructor(alertConfig: AlertConfig = {}) {
|
||||||
|
super();
|
||||||
|
this.startTime = new Date();
|
||||||
|
this.alertConfig = alertConfig;
|
||||||
|
this.metrics = {
|
||||||
|
totalUsers: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
totalMessages: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
apiCalls: 0,
|
||||||
|
successfulLogins: 0,
|
||||||
|
failedLogins: 0,
|
||||||
|
registrations: 0,
|
||||||
|
propertiesCreated: 0,
|
||||||
|
uptime: 0,
|
||||||
|
memoryUsage: process.memoryUsage()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update metrics every minute
|
||||||
|
setInterval(() => {
|
||||||
|
this.updateMetrics();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
// Clean old errors every hour
|
||||||
|
setInterval(() => {
|
||||||
|
this.cleanOldErrors();
|
||||||
|
}, 3600000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Activity Tracking
|
||||||
|
trackUserActivity(userId: number, action: string, success: boolean = true, context?: any): void {
|
||||||
|
this.activeUsers.add(userId);
|
||||||
|
this.metrics.totalMessages++;
|
||||||
|
|
||||||
|
const logEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
success,
|
||||||
|
context: context ? JSON.stringify(context) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📊 [MONITOR] User:${userId} Action:${action} Success:${success}`, context || '');
|
||||||
|
|
||||||
|
// Track specific actions
|
||||||
|
switch (action) {
|
||||||
|
case 'login':
|
||||||
|
if (success) {
|
||||||
|
this.metrics.successfulLogins++;
|
||||||
|
} else {
|
||||||
|
this.metrics.failedLogins++;
|
||||||
|
this.trackError(`Login failed for user ${userId}`, userId, 'authentication');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'registration':
|
||||||
|
if (success) {
|
||||||
|
this.metrics.registrations++;
|
||||||
|
this.metrics.totalUsers++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'property_created':
|
||||||
|
if (success) {
|
||||||
|
this.metrics.propertiesCreated++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('userActivity', logEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Call Tracking
|
||||||
|
trackApiCall(endpoint: string, method: string, status: number, duration: number): void {
|
||||||
|
this.metrics.apiCalls++;
|
||||||
|
|
||||||
|
const logEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
endpoint,
|
||||||
|
method,
|
||||||
|
status,
|
||||||
|
duration
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`🌐 [API] ${method} ${endpoint} - ${status} (${duration}ms)`);
|
||||||
|
|
||||||
|
if (status >= 400) {
|
||||||
|
this.trackError(`API Error: ${method} ${endpoint} returned ${status}`, undefined, 'api');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('apiCall', logEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Tracking
|
||||||
|
trackError(error: string, userId?: number, context?: string): void {
|
||||||
|
this.metrics.errorCount++;
|
||||||
|
|
||||||
|
const errorEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
error,
|
||||||
|
userId,
|
||||||
|
context
|
||||||
|
};
|
||||||
|
|
||||||
|
this.recentErrors.push(errorEntry);
|
||||||
|
this.metrics.lastError = errorEntry;
|
||||||
|
|
||||||
|
console.error(`🚨 [ERROR] ${error}`, { userId, context });
|
||||||
|
|
||||||
|
// Send alert for critical errors
|
||||||
|
this.sendAlert('error', `🚨 Bot Error: ${error}`, {
|
||||||
|
userId,
|
||||||
|
context,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit('error', errorEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// System Health Check
|
||||||
|
getHealthStatus(): { status: 'healthy' | 'warning' | 'critical'; issues: string[] } {
|
||||||
|
const issues: string[] = [];
|
||||||
|
let status: 'healthy' | 'warning' | 'critical' = 'healthy';
|
||||||
|
|
||||||
|
// Check error rate (last hour)
|
||||||
|
const recentErrorCount = this.recentErrors.filter(
|
||||||
|
e => Date.now() - e.timestamp.getTime() < 3600000
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (recentErrorCount > 50) {
|
||||||
|
status = 'critical';
|
||||||
|
issues.push(`High error rate: ${recentErrorCount} errors in last hour`);
|
||||||
|
} else if (recentErrorCount > 10) {
|
||||||
|
status = 'warning';
|
||||||
|
issues.push(`Elevated error rate: ${recentErrorCount} errors in last hour`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check memory usage
|
||||||
|
const memUsage = process.memoryUsage();
|
||||||
|
const memUsageMB = memUsage.heapUsed / 1024 / 1024;
|
||||||
|
|
||||||
|
if (memUsageMB > 500) {
|
||||||
|
status = status === 'critical' ? 'critical' : 'warning';
|
||||||
|
issues.push(`High memory usage: ${memUsageMB.toFixed(2)}MB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check failed login rate
|
||||||
|
const failedLoginRate = this.metrics.failedLogins / Math.max(this.metrics.successfulLogins, 1);
|
||||||
|
if (failedLoginRate > 0.5) {
|
||||||
|
status = status === 'critical' ? 'critical' : 'warning';
|
||||||
|
issues.push(`High failed login rate: ${(failedLoginRate * 100).toFixed(1)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Current Metrics
|
||||||
|
getMetrics(): SystemMetrics {
|
||||||
|
this.updateMetrics();
|
||||||
|
return { ...this.metrics };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Report
|
||||||
|
generateReport(): string {
|
||||||
|
const health = this.getHealthStatus();
|
||||||
|
const metrics = this.getMetrics();
|
||||||
|
|
||||||
|
return `
|
||||||
|
🤖 **Yaltopia Bot System Report**
|
||||||
|
📅 Generated: ${new Date().toISOString()}
|
||||||
|
|
||||||
|
**System Health: ${health.status.toUpperCase()}**
|
||||||
|
${health.issues.length > 0 ? '⚠️ Issues:\n' + health.issues.map(i => `- ${i}`).join('\n') : '✅ All systems operational'}
|
||||||
|
|
||||||
|
**Usage Statistics:**
|
||||||
|
👥 Total Users: ${metrics.totalUsers}
|
||||||
|
🟢 Active Users (24h): ${metrics.activeUsers}
|
||||||
|
💬 Total Messages: ${metrics.totalMessages}
|
||||||
|
🌐 API Calls: ${metrics.apiCalls}
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
✅ Successful Logins: ${metrics.successfulLogins}
|
||||||
|
❌ Failed Logins: ${metrics.failedLogins}
|
||||||
|
📝 Registrations: ${metrics.registrations}
|
||||||
|
|
||||||
|
**Business Metrics:**
|
||||||
|
🏠 Properties Created: ${metrics.propertiesCreated}
|
||||||
|
|
||||||
|
**System Performance:**
|
||||||
|
⏱️ Uptime: ${Math.floor(metrics.uptime / 3600)}h ${Math.floor((metrics.uptime % 3600) / 60)}m
|
||||||
|
💾 Memory Usage: ${(metrics.memoryUsage.heapUsed / 1024 / 1024).toFixed(2)}MB
|
||||||
|
🚨 Total Errors: ${metrics.errorCount}
|
||||||
|
|
||||||
|
${metrics.lastError ? `**Last Error:**
|
||||||
|
🕐 ${metrics.lastError.timestamp.toISOString()}
|
||||||
|
👤 User: ${metrics.lastError.userId || 'System'}
|
||||||
|
📝 ${metrics.lastError.error}` : '✅ No recent errors'}
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Alert
|
||||||
|
private async sendAlert(type: 'error' | 'warning' | 'info', message: string, data?: any): Promise<void> {
|
||||||
|
const alert = {
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
botName: 'Yaltopia Bot'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`🚨 [ALERT] ${type.toUpperCase()}: ${message}`);
|
||||||
|
|
||||||
|
// Send to admin via Telegram (if configured)
|
||||||
|
if (this.alertConfig.adminChatId) {
|
||||||
|
try {
|
||||||
|
// This would need the bot instance to send messages
|
||||||
|
this.emit('adminAlert', { chatId: this.alertConfig.adminChatId, message: `🚨 ${message}` });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send Telegram alert:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to webhook (if configured)
|
||||||
|
if (this.alertConfig.webhookUrl) {
|
||||||
|
try {
|
||||||
|
await axios.post(this.alertConfig.webhookUrl, alert, {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send webhook alert:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('alert', alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMetrics(): void {
|
||||||
|
this.metrics.uptime = Math.floor((Date.now() - this.startTime.getTime()) / 1000);
|
||||||
|
this.metrics.activeUsers = this.activeUsers.size;
|
||||||
|
this.metrics.memoryUsage = process.memoryUsage();
|
||||||
|
|
||||||
|
// Clear active users older than 24 hours (simplified)
|
||||||
|
// In production, you'd want more sophisticated tracking
|
||||||
|
if (this.metrics.uptime % 86400 === 0) { // Every 24 hours
|
||||||
|
this.activeUsers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanOldErrors(): void {
|
||||||
|
const oneDayAgo = Date.now() - 86400000; // 24 hours
|
||||||
|
this.recentErrors = this.recentErrors.filter(e => e.timestamp.getTime() > oneDayAgo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily Report
|
||||||
|
scheduleDailyReport(): void {
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
tomorrow.setHours(9, 0, 0, 0); // 9 AM daily report
|
||||||
|
|
||||||
|
const msUntilTomorrow = tomorrow.getTime() - now.getTime();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.sendAlert('info', 'Daily System Report', this.generateReport());
|
||||||
|
|
||||||
|
// Schedule next daily report
|
||||||
|
setInterval(() => {
|
||||||
|
this.sendAlert('info', 'Daily System Report', this.generateReport());
|
||||||
|
}, 86400000); // 24 hours
|
||||||
|
}, msUntilTomorrow);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/shared/security/input-validator.ts
Normal file
133
src/shared/security/input-validator.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
export class InputValidator {
|
||||||
|
static readonly MAX_TITLE_LENGTH = 100;
|
||||||
|
static readonly MAX_DESCRIPTION_LENGTH = 500;
|
||||||
|
static readonly MAX_SUBCITY_LENGTH = 50;
|
||||||
|
static readonly MAX_NAME_LENGTH = 100;
|
||||||
|
static readonly MIN_PASSWORD_LENGTH = 6;
|
||||||
|
static readonly MAX_PASSWORD_LENGTH = 128;
|
||||||
|
|
||||||
|
static sanitizeText(input: string): string {
|
||||||
|
return input
|
||||||
|
.trim()
|
||||||
|
.replace(/[<>\"'&]/g, '') // Remove potentially dangerous characters
|
||||||
|
.replace(/\s+/g, ' '); // Normalize whitespace
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateTitle(title: string): { valid: boolean; error?: string } {
|
||||||
|
const sanitized = this.sanitizeText(title);
|
||||||
|
|
||||||
|
if (!sanitized || sanitized.length === 0) {
|
||||||
|
return { valid: false, error: 'Title cannot be empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.length > this.MAX_TITLE_LENGTH) {
|
||||||
|
return { valid: false, error: `Title too long (max ${this.MAX_TITLE_LENGTH} characters)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateDescription(description: string): { valid: boolean; error?: string } {
|
||||||
|
const sanitized = this.sanitizeText(description);
|
||||||
|
|
||||||
|
if (!sanitized || sanitized.length === 0) {
|
||||||
|
return { valid: false, error: 'Description cannot be empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.length > this.MAX_DESCRIPTION_LENGTH) {
|
||||||
|
return { valid: false, error: `Description too long (max ${this.MAX_DESCRIPTION_LENGTH} characters)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateName(name: string): { valid: boolean; error?: string } {
|
||||||
|
const sanitized = this.sanitizeText(name);
|
||||||
|
|
||||||
|
if (!sanitized || sanitized.length === 0) {
|
||||||
|
return { valid: false, error: 'Name cannot be empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.length > this.MAX_NAME_LENGTH) {
|
||||||
|
return { valid: false, error: `Name too long (max ${this.MAX_NAME_LENGTH} characters)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid name pattern (letters, spaces, basic punctuation)
|
||||||
|
if (!/^[a-zA-Z\s\-\.]+$/.test(sanitized)) {
|
||||||
|
return { valid: false, error: 'Name contains invalid characters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
static validatePassword(password: string): { valid: boolean; error?: string } {
|
||||||
|
if (!password || password.length === 0) {
|
||||||
|
return { valid: false, error: 'Password cannot be empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < this.MIN_PASSWORD_LENGTH) {
|
||||||
|
return { valid: false, error: `Password too short (min ${this.MIN_PASSWORD_LENGTH} characters)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length > this.MAX_PASSWORD_LENGTH) {
|
||||||
|
return { valid: false, error: `Password too long (max ${this.MAX_PASSWORD_LENGTH} characters)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateEmail(email: string): { valid: boolean; error?: string } {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
if (!email || email.length === 0) {
|
||||||
|
return { valid: false, error: 'Email cannot be empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return { valid: false, error: 'Invalid email format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email.length > 254) { // RFC 5321 limit
|
||||||
|
return { valid: false, error: 'Email too long' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateNumericInput(input: string, min: number = 0, max: number = Number.MAX_SAFE_INTEGER): { valid: boolean; value?: number; error?: string } {
|
||||||
|
const num = parseFloat(input);
|
||||||
|
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return { valid: false, error: 'Please enter a valid number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (num < min) {
|
||||||
|
return { valid: false, error: `Value must be at least ${min}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (num > max) {
|
||||||
|
return { valid: false, error: `Value must be at most ${max}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, value: num };
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateSubcity(subcity: string): { valid: boolean; error?: string } {
|
||||||
|
const sanitized = this.sanitizeText(subcity);
|
||||||
|
|
||||||
|
if (!sanitized || sanitized.length === 0) {
|
||||||
|
return { valid: false, error: 'Subcity cannot be empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.length > this.MAX_SUBCITY_LENGTH) {
|
||||||
|
return { valid: false, error: `Subcity name too long (max ${this.MAX_SUBCITY_LENGTH} characters)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid subcity pattern (letters, spaces, basic punctuation)
|
||||||
|
if (!/^[a-zA-Z\s\-\.]+$/.test(sanitized)) {
|
||||||
|
return { valid: false, error: 'Subcity name contains invalid characters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/shared/security/rate-limiter.ts
Normal file
37
src/shared/security/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
export class RateLimiter {
|
||||||
|
private userActions = new Map<number, number[]>();
|
||||||
|
private readonly maxRequests: number;
|
||||||
|
private readonly windowMs: number;
|
||||||
|
|
||||||
|
constructor(maxRequests = 10, windowMs = 60000) { // 10 requests per minute
|
||||||
|
this.maxRequests = maxRequests;
|
||||||
|
this.windowMs = windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRateLimited(chatId: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const userRequests = this.userActions.get(chatId) || [];
|
||||||
|
|
||||||
|
// Remove old requests outside the window
|
||||||
|
const validRequests = userRequests.filter(timestamp => now - timestamp < this.windowMs);
|
||||||
|
|
||||||
|
if (validRequests.length >= this.maxRequests) {
|
||||||
|
console.log(`⚠️ Rate limit exceeded for user ${chatId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current request
|
||||||
|
validRequests.push(now);
|
||||||
|
this.userActions.set(chatId, validRequests);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemainingRequests(chatId: number): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const userRequests = this.userActions.get(chatId) || [];
|
||||||
|
const validRequests = userRequests.filter(timestamp => now - timestamp < this.windowMs);
|
||||||
|
|
||||||
|
return Math.max(0, this.maxRequests - validRequests.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/shared/services/api.service.ts
Normal file
160
src/shared/services/api.service.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import { ApiResponse } from '../types';
|
||||||
|
|
||||||
|
export class ApiService {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
|
||||||
|
constructor(baseURL: string) {
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add request interceptor to include auth token
|
||||||
|
this.client.interceptors.request.use((config) => {
|
||||||
|
if (this.accessToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${this.accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccessToken(token: string): void {
|
||||||
|
console.log('🔑 Setting access token for API requests');
|
||||||
|
this.accessToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAccessToken(): void {
|
||||||
|
console.log('🔑 Clearing access token');
|
||||||
|
this.accessToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(url: string): Promise<ApiResponse<T>> {
|
||||||
|
console.log(`🌐 GET request to: ${this.client.defaults.baseURL}${url}`);
|
||||||
|
try {
|
||||||
|
const response = await this.client.get(url);
|
||||||
|
console.log(`✅ GET ${url} - Status: ${response.status}`);
|
||||||
|
return { success: true, data: response.data };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`❌ GET ${url} - Error:`, error.response?.status, error.response?.data?.message || error.message);
|
||||||
|
|
||||||
|
// Handle specific connection errors
|
||||||
|
if (error.code === 'ECONNREFUSED') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Cannot connect to API server. Please ensure the backend is running.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'EPROTO' || error.message?.includes('wrong version number')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'SSL/TLS protocol error. Check if API URL uses correct protocol (http/https).'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T>(url: string, data: any): Promise<ApiResponse<T>> {
|
||||||
|
console.log(`🌐 POST request to: ${this.client.defaults.baseURL}${url}`);
|
||||||
|
console.log(`📤 POST data:`, { ...data, password: data.password ? '[HIDDEN]' : undefined });
|
||||||
|
try {
|
||||||
|
const response = await this.client.post(url, data);
|
||||||
|
console.log(`✅ POST ${url} - Status: ${response.status}`);
|
||||||
|
return { success: true, data: response.data };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`❌ POST ${url} - Error:`, error.response?.status, error.response?.data?.message || error.message);
|
||||||
|
|
||||||
|
// Handle specific connection errors
|
||||||
|
if (error.code === 'ECONNREFUSED') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Cannot connect to API server. Please ensure the backend is running.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'EPROTO' || error.message?.includes('wrong version number')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'SSL/TLS protocol error. Check if API URL uses correct protocol (http/https).'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch<T>(url: string, data: any): Promise<ApiResponse<T>> {
|
||||||
|
console.log(`🌐 PATCH request to: ${this.client.defaults.baseURL}${url}`);
|
||||||
|
console.log(`📤 PATCH data:`, { ...data, password: data.password ? '[HIDDEN]' : undefined });
|
||||||
|
try {
|
||||||
|
const response = await this.client.patch(url, data);
|
||||||
|
console.log(`✅ PATCH ${url} - Status: ${response.status}`);
|
||||||
|
return { success: true, data: response.data };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`❌ PATCH ${url} - Error:`, error.response?.status, error.response?.data?.message || error.message);
|
||||||
|
|
||||||
|
// Handle specific connection errors
|
||||||
|
if (error.code === 'ECONNREFUSED') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Cannot connect to API server. Please ensure the backend is running.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'EPROTO' || error.message?.includes('wrong version number')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'SSL/TLS protocol error. Check if API URL uses correct protocol (http/https).'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete<T>(url: string): Promise<ApiResponse<T>> {
|
||||||
|
console.log(`🌐 DELETE request to: ${this.client.defaults.baseURL}${url}`);
|
||||||
|
try {
|
||||||
|
const response = await this.client.delete(url);
|
||||||
|
console.log(`✅ DELETE ${url} - Status: ${response.status}`);
|
||||||
|
return { success: true, data: response.data };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`❌ DELETE ${url} - Error:`, error.response?.status, error.response?.data?.message || error.message);
|
||||||
|
|
||||||
|
// Handle specific connection errors
|
||||||
|
if (error.code === 'ECONNREFUSED') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Cannot connect to API server. Please ensure the backend is running.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'EPROTO' || error.message?.includes('wrong version number')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'SSL/TLS protocol error. Check if API URL uses correct protocol (http/https).'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/shared/services/session.service.ts
Normal file
33
src/shared/services/session.service.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { BotSession } from '../types';
|
||||||
|
|
||||||
|
export class SessionService {
|
||||||
|
private sessions: Map<number, BotSession> = new Map();
|
||||||
|
|
||||||
|
getSession(chatId: number): BotSession {
|
||||||
|
if (!this.sessions.has(chatId)) {
|
||||||
|
this.sessions.set(chatId, {
|
||||||
|
chatId,
|
||||||
|
step: 'START',
|
||||||
|
data: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.sessions.get(chatId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSession(chatId: number, updates: Partial<BotSession>): void {
|
||||||
|
const session = this.getSession(chatId);
|
||||||
|
Object.assign(session, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSession(chatId: number): void {
|
||||||
|
this.sessions.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionStep(chatId: number, step: string, data?: Record<string, any>): void {
|
||||||
|
const session = this.getSession(chatId);
|
||||||
|
session.step = step;
|
||||||
|
if (data) {
|
||||||
|
Object.assign(session.data, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/shared/types/index.ts
Normal file
45
src/shared/types/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
role: 'AGENT' | 'USER';
|
||||||
|
access_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRegistration {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
role: 'AGENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Property {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
type: 'RENT' | 'SELL';
|
||||||
|
price: number;
|
||||||
|
area: number;
|
||||||
|
rooms: number;
|
||||||
|
toilets: number;
|
||||||
|
subcity: string;
|
||||||
|
houseType: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED';
|
||||||
|
taxRate: number;
|
||||||
|
isTaxable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotSession {
|
||||||
|
chatId: number;
|
||||||
|
step: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user