feat: Initialize Yaltopia Telegram bot project with core architecture

This commit is contained in:
debudebuye 2026-01-10 09:26:32 +03:00
commit 23a455db95
20 changed files with 5980 additions and 0 deletions

5
.env.example Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View 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
View 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.');
}
}

View 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 } }
);
}
}

View 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
};
}
}

View 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}\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
}
}
);
}
}

View 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
View 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);
});

View 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.'
);
}
}

View 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) });
}
}
}

View 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);
}
}

View 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 };
}
}

View 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);
}
}

View 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
};
}
}
}

View 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
View 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
View 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"]
}