Initial commit: Yaltipia Telegram Bot with API integration

This commit is contained in:
debudebuye 2026-01-01 15:22:54 +03:00
commit c58ed95370
23 changed files with 6593 additions and 0 deletions

50
.dockerignore Normal file
View File

@ -0,0 +1,50 @@
# Node modules
node_modules
npm-debug.log*
# Environment files
.env
.env.local
.env.development
.env.test
.env.production
# Git
.git
.gitignore
# Documentation
*.md
docs/
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Logs
logs/
*.log
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# Docker files
Dockerfile*
docker-compose*
.dockerignore
# Temporary files
tmp/
temp/

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
TELEGRAM_BOT_TOKEN=your_bot_token_here
API_BASE_URL=your_backend_api_url_here
WEBSITE_URL=https://yaltipia.com/listings

57
.gitignore vendored Normal file
View File

@ -0,0 +1,57 @@
# Dependencies
/node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 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/
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Docker files (keep in git but ignore in docker)
# Dockerfile*
# docker-compose*
# .dockerignore
# Documentation (optional - remove if you want to track them)
BACKEND_API_INTEGRATION.md
ERROR_HANDLING_IMPROVEMENTS.md
WEBSITE_INTEGRATION.md
SECURITY_AUDIT_REPORT.md
# Temporary files
tmp/
temp/
# User data (will be mounted as volume in Docker)

35
Dockerfile Normal file
View File

@ -0,0 +1,35 @@
# Use official Node.js runtime as base image
FROM node:18-alpine
# Set working directory in container
WORKDIR /app
# Copy package files first (for better caching)
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY src/ ./src/
# Create data directory for user storage
RUN mkdir -p ./src/data
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs
RUN adduser -S botuser -u 1001
# Change ownership of app directory
RUN chown -R botuser:nodejs /app
USER botuser
# Expose port (if needed for health checks)
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "console.log('Bot is running')" || exit 1
# Start the bot
CMD ["node", "src/bot.js"]

34
Dockerfile.dev Normal file
View File

@ -0,0 +1,34 @@
# Development Dockerfile with hot reload
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Install nodemon globally for development
RUN npm install -g nodemon
# Copy package files
COPY package*.json ./
# Install all dependencies (including dev dependencies)
RUN npm install
# Copy source code
COPY src/ ./src/
# Create data directory
RUN mkdir -p ./src/data
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S botuser -u 1001
# Change ownership
RUN chown -R botuser:nodejs /app
USER botuser
# Expose debug port
EXPOSE 9229
# Start with nodemon for hot reload
CMD ["nodemon", "--inspect=0.0.0.0:9229", "src/bot.js"]

31
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,31 @@
version: '3.8'
services:
yaltipia-telegram-bot-dev:
build:
context: .
dockerfile: Dockerfile.dev
container_name: yaltipia-telegram-bot-dev
restart: unless-stopped
environment:
- NODE_ENV=development
env_file:
- .env
volumes:
# Mount source code for development (hot reload)
- ./src:/app/src
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
# Mount logs directory
- ./logs:/app/logs
# Exclude node_modules from host
- /app/node_modules
networks:
- yaltipia-dev-network
ports:
# Optional: expose port for debugging
- "9229:9229"
networks:
yaltipia-dev-network:
driver: bridge

44
docker-compose.yml Normal file
View File

@ -0,0 +1,44 @@
version: '3.8'
services:
yaltipia-telegram-bot:
build: .
container_name: yaltipia-telegram-bot
restart: unless-stopped
environment:
- NODE_ENV=production
env_file:
- .env
volumes:
# Persist user data
- ./src/data:/app/src/data
# Mount logs directory (optional)
- ./logs:/app/logs
networks:
- yaltipia-network
depends_on:
- backend-api
healthcheck:
test: ["CMD", "node", "-e", "console.log('Bot is running')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Optional: If you want to run your backend API in the same compose
backend-api:
# Replace with your backend image or build context
image: your-backend-api:latest
container_name: yaltipia-backend-api
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
networks:
- yaltipia-network
# Add your backend-specific configuration here
networks:
yaltipia-network:
driver: bridge

3356
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "yaltipia-telegram-bot",
"version": "1.0.0",
"description": "Telegram bot for Yaltipia home clients to manage property notifications",
"main": "src/bot.js",
"scripts": {
"start": "node src/bot.js",
"dev": "nodemon src/bot.js"
},
"dependencies": {
"axios": "^1.6.0",
"bcrypt": "^5.1.1",
"crypto": "^1.0.1",
"dotenv": "^16.3.1",
"node-telegram-bot-api": "^0.67.0",
"validator": "^13.11.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
},
"keywords": [
"telegram",
"bot",
"real-estate",
"notifications"
],
"author": "Yaltipia",
"license": "MIT"
}

21
scripts/docker-build.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# Docker build script for Yaltipia Telegram Bot
echo "🐳 Building Yaltipia Telegram Bot Docker image..."
# Build production image
docker build -t yaltipia-telegram-bot:latest .
# Build development image
docker build -f Dockerfile.dev -t yaltipia-telegram-bot:dev .
echo "✅ Docker images built successfully!"
echo ""
echo "Available images:"
docker images | grep yaltipia-telegram-bot
echo ""
echo "🚀 To run the bot:"
echo "Production: docker-compose up -d"
echo "Development: docker-compose -f docker-compose.dev.yml up -d"

0
scripts/docker-run.sh Normal file
View File

465
src/api.js Normal file
View File

@ -0,0 +1,465 @@
const axios = require('axios');
const ErrorHandler = require('./utils/errorHandler');
class ApiClient {
constructor() {
this.baseURL = process.env.API_BASE_URL || 'http://localhost:3000/api';
this.client = axios.create({
baseURL: this.baseURL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// Store user tokens for authenticated requests
this.userTokens = new Map(); // telegramId -> token
}
// Set authentication token for a user
setUserToken(telegramId, token) {
if (token === null || token === undefined) {
this.userTokens.delete(telegramId);
} else {
this.userTokens.set(telegramId, token);
}
}
// Get authentication token for a user
getUserToken(telegramId) {
return this.userTokens.get(telegramId);
}
// Create authenticated headers for a user
getAuthHeaders(telegramId) {
const token = this.getUserToken(telegramId);
if (token) {
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
}
return {
'Content-Type': 'application/json'
};
}
async registerUser(userData, telegramUserId) {
try {
console.log('Attempting telegram registration with data:', {
name: userData.name,
email: userData.email,
phone: userData.phone,
telegramUserId: telegramUserId
});
if (!userData.email) {
return {
success: false,
error: 'Email is required for registration. Please provide an email address.'
};
}
const response = await this.client.post('/telegram-auth/telegram-register', {
name: userData.name,
email: userData.email,
phone: userData.phone,
password: userData.password,
telegramUserId: telegramUserId.toString()
});
console.log('Telegram registration successful for:', userData.phone);
console.log('Registration response structure:', Object.keys(response.data));
// Handle different possible response structures
const user = response.data.user || response.data.data || response.data;
const token = response.data.token || response.data.accessToken || response.data.access_token;
if (token) {
console.log('Token received from registration');
} else {
console.log('No token in registration response');
}
return {
success: true,
data: response.data,
user: user,
token: token
};
} catch (error) {
ErrorHandler.logError(error, 'telegram_registration');
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'register')
};
}
}
async loginUser(phone, password, telegramUserId) {
try {
console.log('Attempting telegram login with phone:', phone);
const response = await this.client.post('/telegram-auth/telegram-login', {
phone: phone,
password: password,
telegramUserId: telegramUserId.toString()
});
console.log('Telegram login successful for phone:', phone);
console.log('Login response structure:', Object.keys(response.data));
// Handle different possible response structures
const user = response.data.user || response.data.data || response.data;
const token = response.data.token || response.data.accessToken || response.data.access_token;
if (token) {
console.log('Token received from login');
} else {
console.log('No token in login response');
}
return {
success: true,
data: response.data,
user: user,
token: token
};
} catch (error) {
ErrorHandler.logError(error, 'telegram_login');
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'login')
};
}
}
async getUserByPhone(phone) {
try {
console.log('Checking user existence for phone:', phone);
// Use the new telegram-auth endpoint to check phone existence
const response = await this.client.get(`/telegram-auth/phone/${encodeURIComponent(phone)}/check`);
console.log('Phone check response:', response.data);
// Check for hasAccount instead of exists (based on actual API response)
if (response.data.hasAccount === true || response.data.flow === 'login') {
console.log('User exists in database');
return {
success: true,
user: {
phone: phone,
name: response.data.user?.name || 'User',
email: response.data.user?.email,
id: response.data.user?.id
}
};
} else if (response.data.hasAccount === false || response.data.flow === 'register') {
console.log('User does not exist in database');
return {
success: false,
error: 'User not found'
};
} else {
// Fallback for unexpected response format
console.log('Unexpected response format, treating as user not found');
return {
success: false,
error: 'User not found'
};
}
} catch (error) {
ErrorHandler.logError(error, 'phone_check');
if (error.response?.status === 404) {
return {
success: false,
error: 'User not found'
};
}
if (error.response?.status === 429) {
return {
success: false,
error: 'Too many requests. Please try again later.'
};
}
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'phone_check')
};
}
}
// Alternative method to check user existence by attempting login with a dummy password
async checkUserExistsByLogin(phone) {
try {
console.log('Checking user existence by attempting login for phone:', phone);
// Try login with a dummy password to see if user exists
const response = await this.client.post('/auth/login', {
identifier: phone,
password: 'dummy_password_to_check_existence'
});
// If we get here, user exists but password was wrong (shouldn't happen)
return {
success: true,
userExists: true
};
} catch (error) {
if (error.response?.status === 401) {
// Unauthorized - user exists but password is wrong
console.log('User exists (got 401 unauthorized)');
return {
success: true,
userExists: true
};
} else if (error.response?.status === 404 ||
(error.response?.data?.message &&
error.response.data.message.toLowerCase().includes('user not found'))) {
// User not found
console.log('User does not exist');
return {
success: true,
userExists: false
};
}
console.log('Could not determine user existence:', error.response?.data || error.message);
return {
success: false,
error: error.response?.data?.message || error.message
};
}
}
async validateEmail(email) {
try {
console.log('Validating email:', email);
const response = await this.client.get(`/telegram-auth/validate-email/${encodeURIComponent(email)}`);
console.log('Email validation response:', response.data);
return {
success: true,
valid: response.data.valid,
available: response.data.available,
message: response.data.message
};
} catch (error) {
ErrorHandler.logError(error, 'email_validation');
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'email_validation')
};
}
}
async createNotification(telegramId, userId, notificationData) {
try {
console.log('Creating notification via API for user:', userId);
const response = await this.client.post('/telegram-notifications', {
name: notificationData.name,
type: notificationData.type,
status: notificationData.status,
subcity: notificationData.subcity,
houseType: notificationData.houseType,
minPrice: notificationData.minPrice,
maxPrice: notificationData.maxPrice,
telegramUserId: telegramId.toString()
}, {
headers: this.getAuthHeaders(telegramId)
});
console.log('Notification created successfully');
return {
success: true,
data: response.data
};
} catch (error) {
ErrorHandler.logError(error, 'create_notification');
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'create_notification')
};
}
}
async getUserNotifications(telegramId, userId) {
try {
console.log('Getting user notifications via API for user:', userId);
const response = await this.client.get('/telegram-notifications/my-notifications', {
headers: this.getAuthHeaders(telegramId)
});
console.log('Retrieved notifications successfully');
console.log('API response structure:', Object.keys(response.data));
// Handle different possible response structures
let notifications = [];
if (response.data.notifications) {
notifications = response.data.notifications;
} else if (response.data.data && Array.isArray(response.data.data)) {
notifications = response.data.data;
} else if (Array.isArray(response.data)) {
notifications = response.data;
}
console.log('Parsed notifications count:', notifications.length);
return {
success: true,
notifications: notifications
};
} catch (error) {
ErrorHandler.logError(error, 'get_notifications');
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'get_notifications')
};
}
}
async getNotificationMatches(telegramId, notificationId) {
try {
console.log('Getting notification matches for notification:', notificationId);
const response = await this.client.get(`/telegram-notifications/${notificationId}/matches`, {
headers: this.getAuthHeaders(telegramId)
});
console.log('Retrieved matches successfully');
return {
success: true,
listings: response.data
};
} catch (error) {
ErrorHandler.logError(error, 'get_matches');
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'get_matches')
};
}
}
async deleteNotification(telegramId, notificationId) {
try {
console.log('Deleting notification:', notificationId);
await this.client.delete(`/telegram-notifications/${notificationId}`, {
headers: this.getAuthHeaders(telegramId)
});
console.log('Notification deleted successfully');
return {
success: true
};
} catch (error) {
ErrorHandler.logError(error, 'delete_notification');
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'delete_notification')
};
}
}
async getNotificationsByTelegramId(telegramUserId) {
try {
console.log('Getting notifications by telegram ID:', telegramUserId);
// Use the public endpoint that doesn't require authentication
const response = await this.client.get(`/telegram-notifications/telegram/${telegramUserId}`);
console.log('Retrieved notifications by telegram ID successfully');
return {
success: true,
notifications: response.data
};
} catch (error) {
ErrorHandler.logError(error, 'get_notifications_by_telegram_id');
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'get_notifications')
};
}
}
async getListings(telegramId = null, filters = {}) {
try {
const params = new URLSearchParams();
// Add filters to query parameters (only if they have values)
if (filters.type) params.append('type', filters.type);
if (filters.status) params.append('status', filters.status);
if (filters.minPrice && filters.minPrice > 0) params.append('minPrice', filters.minPrice);
if (filters.maxPrice && filters.maxPrice > 0) params.append('maxPrice', filters.maxPrice);
if (filters.subcity && filters.subcity.toLowerCase() !== 'any') params.append('subcity', filters.subcity);
if (filters.houseType && filters.houseType.toLowerCase() !== 'any') params.append('houseType', filters.houseType);
// Use auth headers if telegramId is provided
const config = telegramId ? { headers: this.getAuthHeaders(telegramId) } : {};
console.log('API call with filters:', Object.fromEntries(params));
const response = await this.client.get(`/listings?${params.toString()}`, config);
return {
success: true,
listings: response.data
};
} catch (error) {
ErrorHandler.logError(error, 'get_listings');
return {
success: false,
error: ErrorHandler.getUserFriendlyMessage(error, 'get_listings')
};
}
}
async checkMatchingListings(telegramId, notificationFilters) {
// Use the regular getListings method for matching
return this.getListings(telegramId, notificationFilters);
}
async updateNotification(telegramId, notificationId, updateData) {
try {
console.log('Updating notification via API:', notificationId, updateData);
// Use PATCH for partial updates (more semantically correct)
const response = await this.client.patch(`/telegram-notifications/${notificationId}`, updateData, {
headers: this.getAuthHeaders(telegramId)
});
console.log('Notification updated successfully');
return {
success: true,
data: response.data
};
} catch (error) {
console.error('Update notification error:', error.response?.data || error.message);
return {
success: false,
error: error.response?.data?.message || error.message
};
}
}
// Generate a random password for users (since they register via Telegram)
generatePassword() {
return Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-8);
}
}
module.exports = ApiClient;

299
src/bot.js Normal file
View File

@ -0,0 +1,299 @@
require('dotenv').config();
const TelegramBot = require('node-telegram-bot-api');
const ApiClient = require('./api');
const NotificationService = require('./services/notificationService');
const ErrorHandler = require('./utils/errorHandler');
// Import feature modules
const AuthFeature = require('./features/auth');
const NotificationFeature = require('./features/notifications');
const SearchFeature = require('./features/search');
const MenuFeature = require('./features/menu');
class YaltipiaBot {
constructor() {
// Configure bot with better error handling
this.bot = new TelegramBot(process.env.TELEGRAM_BOT_TOKEN, {
polling: {
interval: 1000,
autoStart: true,
params: {
timeout: 10
}
}
});
this.api = new ApiClient();
this.notificationService = new NotificationService(this.api);
this.userStates = new Map(); // Store user conversation states
this.userSessions = new Map(); // Store user session data
console.log('Bot initialized with notification service:', !!this.notificationService);
// Initialize features
this.auth = new AuthFeature(this.bot, this.api, this.userStates, this.userSessions, this.notificationService);
this.notifications = new NotificationFeature(this.bot, this.api, this.userStates, this.userSessions, this.notificationService);
this.search = new SearchFeature(this.bot, this.api, this.userStates, this.userSessions);
this.menu = new MenuFeature(this.bot, this.userStates);
console.log('Features initialized. Notification feature has service:', !!this.notifications.notificationService);
this.setupHandlers();
this.setupErrorHandlers();
}
setupErrorHandlers() {
// Handle polling errors
this.bot.on('polling_error', (error) => {
console.error('Polling error:', error.message);
// Don't restart on network errors, just log them
if (error.code === 'EFATAL' || error.message.includes('ECONNRESET') || error.message.includes('ETIMEDOUT')) {
console.log('Network error detected, continuing...');
return;
}
// For other errors, you might want to restart
console.error('Critical polling error:', error);
});
// Handle webhook errors
this.bot.on('webhook_error', (error) => {
console.error('Webhook error:', error);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
ErrorHandler.logError(reason, 'unhandled_rejection');
// Don't exit the process, just log the error
});
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
ErrorHandler.logError(error, 'uncaught_exception');
console.error('Critical error occurred, but continuing...');
});
}
setupHandlers() {
// Start command
this.bot.onText(/\/start/, (msg) => {
this.auth.handleStart(msg);
});
// Logout command
this.bot.onText(/\/logout/, (msg) => {
this.auth.handleLogout(msg);
});
// Login command
this.bot.onText(/\/login/, (msg) => {
this.auth.handleLogin(msg);
});
// Session status command (for debugging)
this.bot.onText(/\/session/, (msg) => {
const telegramId = msg.from.id;
const chatId = msg.chat.id;
const sessionInfo = this.auth.getSessionInfo(telegramId);
if (sessionInfo.exists) {
this.bot.sendMessage(chatId,
`🔍 Session Status:\n\n` +
`✅ Logged in: Yes\n` +
`👤 User: ${sessionInfo.userName}\n` +
`📱 Phone: ${sessionInfo.phone}\n` +
`🕐 Login time: ${sessionInfo.loginTime}\n` +
`🆔 User ID: ${sessionInfo.userId}`
);
} else {
this.bot.sendMessage(chatId,
`🔍 Session Status:\n\n` +
`❌ Logged in: No\n\n` +
`Use /start to login or register.`
);
}
});
// Handle contact sharing
this.bot.on('contact', (msg) => {
this.auth.handleContact(msg);
});
// Handle text messages
this.bot.on('message', (msg) => {
if (msg.text && !msg.text.startsWith('/') && !msg.contact) {
this.handleTextMessage(msg);
}
});
// Handle callback queries (inline keyboard buttons)
this.bot.on('callback_query', (query) => {
this.handleCallbackQuery(query);
});
}
async handleTextMessage(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
try {
// Try each feature's text handler in order
const handled =
await this.auth.handleRegistrationText(msg) ||
await this.notifications.handleNotificationText(msg) ||
await this.notifications.handleEditText(msg) || // Add edit text handler
await this.search.handleSearchText(msg);
if (!handled) {
// If no feature handled the message and user is authenticated, show menu
if (this.auth.isAuthenticated(telegramId)) {
this.menu.showMainMenu(chatId);
} else {
this.bot.sendMessage(chatId, 'Please start with /start to register or login.');
}
}
} catch (error) {
ErrorHandler.logError(error, 'text_message_handler');
this.bot.sendMessage(chatId,
'❌ Something went wrong. Please try again or use /start to restart.'
);
}
}
async handleCallbackQuery(query) {
const chatId = query.message.chat.id;
const telegramId = query.from.id;
const data = query.data;
try {
let handled = false;
// Route callback queries to appropriate features
switch (data) {
// Auth callbacks
case 'skip_email':
handled = await this.auth.handleSkipEmail(chatId, telegramId);
break;
// Menu callbacks
case 'create_notification':
await this.notifications.startNotificationCreation(chatId, telegramId);
handled = true;
break;
case 'view_notifications':
await this.notifications.showUserNotifications(chatId, telegramId);
handled = true;
break;
case 'back_to_menu':
handled = await this.menu.handleBackToMenu(chatId, telegramId);
break;
case 'logout':
handled = await this.auth.handleLogout({ chat: { id: chatId }, from: { id: telegramId } });
break;
// Notification callbacks
case 'preview_matches':
handled = await this.notifications.previewMatches(chatId, telegramId);
break;
case 'save_notification':
handled = await this.notifications.saveNotification(chatId, telegramId);
if (handled) {
this.menu.showMainMenu(chatId);
}
break;
// Dynamic callbacks (type_, status_, edit_, delete_)
default:
if (data.startsWith('type_')) {
const type = data.replace('type_', '');
handled =
await this.notifications.setNotificationType(chatId, telegramId, type) ||
await this.search.setSearchType(chatId, telegramId, type);
} else if (data.startsWith('status_')) {
const status = data.replace('status_', '');
handled =
await this.notifications.setNotificationStatus(chatId, telegramId, status) ||
await this.search.setSearchStatus(chatId, telegramId, status);
} else if (data.startsWith('edit_notification_')) {
const notificationId = data.replace('edit_notification_', '');
handled = await this.notifications.editNotification(chatId, telegramId, notificationId);
} else if (data.startsWith('delete_notification_')) {
const notificationId = data.replace('delete_notification_', '');
handled = await this.notifications.deleteNotification(chatId, telegramId, notificationId);
} else if (data.startsWith('confirm_delete_')) {
const notificationId = data.replace('confirm_delete_', '');
handled = await this.notifications.confirmDeleteNotification(chatId, telegramId, notificationId);
} else if (data.startsWith('edit_name_')) {
const notificationId = data.replace('edit_name_', '');
handled = await this.notifications.startEditField(chatId, telegramId, notificationId, 'name');
} else if (data.startsWith('edit_type_')) {
const notificationId = data.replace('edit_type_', '');
handled = await this.notifications.startEditField(chatId, telegramId, notificationId, 'type');
} else if (data.startsWith('edit_status_')) {
const notificationId = data.replace('edit_status_', '');
handled = await this.notifications.startEditField(chatId, telegramId, notificationId, 'status');
} else if (data.startsWith('edit_area_')) {
const notificationId = data.replace('edit_area_', '');
handled = await this.notifications.startEditField(chatId, telegramId, notificationId, 'area');
} else if (data.startsWith('edit_house_type_')) {
const notificationId = data.replace('edit_house_type_', '');
handled = await this.notifications.startEditField(chatId, telegramId, notificationId, 'house_type');
} else if (data.startsWith('edit_price_')) {
const notificationId = data.replace('edit_price_', '');
handled = await this.notifications.startEditField(chatId, telegramId, notificationId, 'price');
} else if (data.startsWith('update_type_')) {
const parts = data.replace('update_type_', '').split('_');
const type = parts[0];
const notificationId = parts[1];
handled = await this.notifications.updateNotificationDirectly(chatId, telegramId, notificationId, { type });
} else if (data.startsWith('update_status_')) {
const parts = data.replace('update_status_', '').split('_');
const status = parts[0];
const notificationId = parts[1];
handled = await this.notifications.updateNotificationDirectly(chatId, telegramId, notificationId, { status });
}
break;
}
if (!handled) {
console.log(`Unhandled callback query: ${data}`);
// Try to show main menu as fallback
if (this.auth.isAuthenticated(telegramId)) {
this.menu.showMainMenu(chatId);
}
}
this.bot.answerCallbackQuery(query.id);
} catch (error) {
ErrorHandler.logError(error, 'callback_query_handler');
// Send user-friendly error message
this.bot.sendMessage(chatId,
'❌ Something went wrong. Please try again or return to the main menu.'
);
// Try to show main menu as fallback
if (this.auth.isAuthenticated(telegramId)) {
this.menu.showMainMenu(chatId);
}
this.bot.answerCallbackQuery(query.id, { text: 'Error occurred' });
}
}
}
// Start the bot
const bot = new YaltipiaBot();
console.log('🤖 Yaltipia Telegram Bot is running...');
// Graceful shutdown
process.on('SIGINT', () => {
console.log('Shutting down bot...');
process.exit(0);
});

592
src/features/auth.js Normal file
View File

@ -0,0 +1,592 @@
class AuthFeature {
constructor(bot, api, userStates, userSessions, notificationService = null) {
this.bot = bot;
this.api = api;
this.userStates = userStates;
this.userSessions = userSessions;
this.notificationService = notificationService;
}
async handleStart(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
try {
// Check if user is already logged in
const existingSession = this.userSessions.get(telegramId);
if (existingSession && existingSession.user) {
this.showMainMenu(chatId);
return;
}
// Always start with phone number request
this.userStates.set(telegramId, { step: 'waiting_phone' });
const keyboard = {
keyboard: [[{
text: '📱 Share Phone Number',
request_contact: true
}]],
resize_keyboard: true,
one_time_keyboard: true
};
this.bot.sendMessage(chatId,
'🏠 Welcome to Yaltipia Home Bot!\n\n' +
'To get started, please share your phone number by clicking the button below:',
{ reply_markup: keyboard }
);
} catch (error) {
console.error('Error in handleStart:', error);
this.bot.sendMessage(chatId, 'Sorry, something went wrong. Please try again.');
}
}
async handleLogin(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
try {
// Check if user is already logged in
const existingSession = this.userSessions.get(telegramId);
if (existingSession && existingSession.user) {
this.bot.sendMessage(chatId,
`✅ You are already logged in as ${existingSession.user.name || 'User'}!`
);
this.showMainMenu(chatId);
return;
}
// User not found - direct them to register
this.bot.sendMessage(chatId,
'❌ Please use /start to login with your phone number and password.'
);
} catch (error) {
console.error('Error in handleLogin:', error);
this.bot.sendMessage(chatId, 'Sorry, something went wrong. Please try again.');
}
}
async handleContact(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
const userState = this.userStates.get(telegramId);
if (!userState || userState.step !== 'waiting_phone') {
return;
}
const phoneNumber = msg.contact.phone_number;
console.log('Checking user existence for phone:', phoneNumber);
try {
// Check if user exists in backend database
const existingUser = await this.api.getUserByPhone(phoneNumber);
console.log('User existence check result:', existingUser);
if (existingUser.success) {
// User exists in backend, ask for password to login
console.log('User found in database');
this.userStates.set(telegramId, {
step: 'waiting_password',
userData: {
phone: phoneNumber,
telegramId: telegramId,
// We might not have the name yet if using /exists endpoint
name: existingUser.user.name || 'User'
}
});
const welcomeMessage = existingUser.user.name && existingUser.user.name !== 'User'
? `Welcome back, ${existingUser.user.name}!`
: 'Welcome back!';
this.bot.sendMessage(chatId,
`✅ Phone number recognized!\n\n` +
`${welcomeMessage}\n` +
'🔐 Please enter your password to login:',
{ reply_markup: { remove_keyboard: true } }
);
return;
}
// User not found in backend, continue with registration
console.log('User not found in database, proceeding with registration');
userState.phoneNumber = phoneNumber;
userState.step = 'waiting_name';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'✅ Phone number received!\n\n' +
'Now, please enter your full name:',
{ reply_markup: { remove_keyboard: true } }
);
} catch (error) {
console.error('Error checking existing user:', error);
// Handle rate limiting specifically
if (error.response?.status === 429 || existingUser?.error?.includes('Too many requests')) {
console.log('Rate limit detected, asking user to try again later');
this.bot.sendMessage(chatId,
'⏳ Server is busy right now. Please wait a moment and try again.\n\n' +
'Click /start to try again in a few seconds.',
{ reply_markup: { remove_keyboard: true } }
);
return;
}
// For other errors, proceed with registration flow
// and handle conflicts during registration if user already exists
console.log('Error occurred, proceeding with registration flow');
userState.phoneNumber = phoneNumber;
userState.step = 'waiting_name';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'✅ Phone number received!\n\n' +
'Now, please enter your full name:',
{ reply_markup: { remove_keyboard: true } }
);
}
}
async handleRegistrationText(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
const userState = this.userStates.get(telegramId);
if (!userState) {
return false; // Not handling this message
}
try {
switch (userState.step) {
case 'waiting_name':
userState.name = msg.text.trim();
userState.step = 'waiting_email';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'✅ Name received!\n\n' +
'📧 Please enter your email address (required):'
);
return true;
case 'waiting_email':
const email = msg.text.trim();
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
this.bot.sendMessage(chatId,
'❌ Please enter a valid email address:'
);
return true;
}
// Check if email already exists using the new validation endpoint
const emailCheck = await this.api.validateEmail(email);
if (emailCheck.success && !emailCheck.available) {
this.bot.sendMessage(chatId,
'❌ This email address is already registered.\n\n' +
'This means you might already have an account. Please try one of these options:\n\n' +
'1⃣ Enter a different email address\n' +
'2⃣ Use /start to login with your existing account\n\n' +
'Please enter a different email address or use /start to login:'
);
return true;
}
userState.email = email;
userState.step = 'waiting_password';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'✅ Email received!\n\n' +
'🔐 Now, please create a password for your account:\n' +
'(Password should be at least 6 characters)'
);
return true;
case 'waiting_password':
const password = msg.text.trim();
// Check if this is for login or registration
if (userState.userData) {
// This is a login attempt
return await this.handlePasswordLogin(chatId, telegramId, password, userState);
} else {
// This is password creation during registration
if (password.length < 6) {
this.bot.sendMessage(chatId,
'❌ Password must be at least 6 characters long. Please try again:'
);
return true;
}
userState.password = password;
userState.step = 'waiting_password_confirm';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'🔐 Please confirm your password by typing it again:'
);
return true;
}
case 'waiting_password_confirm':
const confirmPassword = msg.text.trim();
if (confirmPassword !== userState.password) {
this.bot.sendMessage(chatId,
'❌ Passwords do not match. Please enter your password again:'
);
userState.step = 'waiting_password';
this.userStates.set(telegramId, userState);
return true;
}
await this.completeRegistration(chatId, telegramId, userState);
return true;
}
} catch (error) {
console.error('Error in handleRegistrationText:', error);
this.bot.sendMessage(chatId, 'Sorry, something went wrong. Please try again.');
}
return false;
}
async handlePasswordLogin(chatId, telegramId, password, userState) {
try {
const userData = userState.userData;
// Login to backend with phone, password, and telegramUserId
const loginResult = await this.api.loginUser(userData.phone, password, telegramId);
console.log('Login result success:', loginResult.success);
console.log('Login result token:', !!loginResult.token);
console.log('Login result user:', !!loginResult.user);
if (loginResult.success) {
let token = loginResult.token;
let user = loginResult.user;
if (!token) {
console.log('No token from login, but login was successful');
// Continue without token - user is authenticated but may need token for some operations
}
// Create session with proper structure
this.userSessions.set(telegramId, {
user: {
id: user.id,
name: user.name || userData.name || 'User',
email: user.email,
phone: userData.phone
},
phoneNumber: userData.phone,
password: password,
loginTime: new Date().toISOString(),
telegramId: telegramId
});
// Store authentication token if we have one
if (token) {
this.api.setUserToken(telegramId, token);
}
this.userStates.delete(telegramId);
const userName = user.name || userData.name || 'User';
this.bot.sendMessage(chatId,
`✅ Welcome back, ${userName}!\n\n` +
'You are now logged in.'
);
this.showMainMenu(chatId);
return true;
} else {
this.bot.sendMessage(chatId,
'❌ Incorrect password. Please try again:\n\n' +
'💡 Tip: Make sure you entered the correct password for your account.'
);
return true;
}
} catch (error) {
console.error('Error in password login:', error);
// Check if it's a 401 Unauthorized error (wrong password)
if (error.response && error.response.status === 401) {
this.bot.sendMessage(chatId,
'❌ Incorrect password. Please try again:\n\n' +
'💡 Tip: Make sure you entered the correct password for your account.'
);
} else {
this.bot.sendMessage(chatId,
'❌ Login failed due to a server error. Please try again later.\n\n' +
'If the problem persists, please contact support.'
);
}
return true;
}
}
async handleSkipEmail(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
if (userState && userState.step === 'waiting_email') {
// Since backend requires email, we can't skip it
this.bot.sendMessage(chatId,
'❌ Email is required for registration.\n\n' +
'Please enter your email address:'
);
return true;
}
return false;
}
async completeRegistration(chatId, telegramId, userState) {
try {
// Register with backend, passing telegramUserId
const registrationResult = await this.api.registerUser({
name: userState.name,
email: userState.email,
phone: userState.phoneNumber,
password: userState.password
}, telegramId); // Pass telegramUserId as second parameter
if (!registrationResult.success) {
// Handle specific error cases
if (registrationResult.error.includes('email already exists')) {
this.bot.sendMessage(chatId,
`❌ Registration failed: An account with this email already exists.\n\n` +
'🔄 Please try with a different email address.\n\n' +
'Please enter a different email address:'
);
// Go back to email step
userState.step = 'waiting_email';
this.userStates.set(telegramId, userState);
return;
} else if (registrationResult.error.includes('phone already exists') ||
registrationResult.error.includes('phone number already exists') ||
registrationResult.error.includes('User with this phone number already exists')) {
// This can happen if we couldn't check phone due to rate limiting
this.bot.sendMessage(chatId,
`❌ Registration failed: This phone number is already registered.\n\n` +
'🔐 It seems you already have an account. Please use /start and enter your password when prompted.\n\n' +
'If you forgot your password, please contact support.'
);
this.userStates.delete(telegramId);
return;
} else {
// Other registration errors
this.bot.sendMessage(chatId,
`❌ Registration failed: ${registrationResult.error}\n\n` +
'Please try again with /start'
);
this.userStates.delete(telegramId);
return;
}
}
// Registration successful, but check if we got a token
let token = registrationResult.token;
let user = registrationResult.user;
if (!token) {
console.log('No token from registration, attempting login to get token...');
// Try to login to get a token
const loginResult = await this.api.loginUser(userState.phoneNumber, userState.password, telegramId);
if (loginResult.success && loginResult.token) {
token = loginResult.token;
user = loginResult.user;
console.log('Login after registration successful, token obtained');
} else {
console.log('Login after registration failed:', loginResult.error);
// Continue without token - user is registered but may need to login manually later
}
}
// Create session with proper structure
this.userSessions.set(telegramId, {
user: {
id: user.id || registrationResult.data?.id,
name: userState.name,
email: userState.email,
phone: userState.phoneNumber
},
phoneNumber: userState.phoneNumber,
password: userState.password,
loginTime: new Date().toISOString(),
telegramId: telegramId
});
// Store authentication token if we have one
if (token) {
console.log('Storing token for user:', telegramId);
this.api.setUserToken(telegramId, token);
} else {
console.log('No token available - user may need to login later for authenticated requests');
}
this.userStates.delete(telegramId);
this.bot.sendMessage(chatId,
'🎉 Registration completed successfully!\n\n' +
`Name: ${userState.name}\n` +
`Phone: ${userState.phoneNumber}\n` +
`Email: ${userState.email}\n\n` +
'✅ Your account has been created and you are now logged in!\n' +
'You can now create property notifications!'
);
this.showMainMenu(chatId);
} catch (error) {
console.error('Error completing registration:', error);
this.bot.sendMessage(chatId, 'Sorry, registration failed due to a server error. Please try again with /start');
}
}
async handleLogout(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
try {
// Check if user is logged in
const userSession = this.userSessions.get(telegramId);
if (!userSession || !userSession.user) {
this.bot.sendMessage(chatId,
'❌ You are not logged in.\n\n' +
'Use /start to register or login.'
);
return true;
}
const userName = userSession.user.name || 'User';
// Clear user session and token
this.userSessions.delete(telegramId);
this.userStates.delete(telegramId);
// Remove authentication token
this.api.setUserToken(telegramId, null);
// Clear user notifications if notification service is available
if (this.notificationService && userSession.user.id) {
this.notificationService.clearUserNotifications(userSession.user.id);
}
this.bot.sendMessage(chatId,
`✅ Goodbye ${userName}!\n\n` +
'You have been logged out successfully.\n\n' +
'Use /start to login again when you want to use the bot.'
);
return true;
} catch (error) {
console.error('Error in handleLogout:', error);
this.bot.sendMessage(chatId, 'Sorry, something went wrong during logout. Please try again.');
return false;
}
}
showMainMenu(chatId, telegramId = null) {
// If telegramId is provided, validate session first
if (telegramId && !this.validateSession(telegramId)) {
this.bot.sendMessage(chatId,
'❌ Your session has expired. Please login again with /start'
);
return;
}
const websiteUrl = process.env.WEBSITE_URL || 'https://yaltipia.com/listings';
const keyboard = {
inline_keyboard: [
[{ text: '🔔 Create Notification', callback_data: 'create_notification' }],
[{ text: '📋 View My Notifications', callback_data: 'view_notifications' }],
[{ text: '🌐 Browse All Listings', url: websiteUrl }],
[{ text: '🚪 Logout', callback_data: 'logout' }]
]
};
this.bot.sendMessage(chatId,
'🏠 Yaltipia Home Bot - Main Menu\n\n' +
'What would you like to do?',
{ reply_markup: keyboard }
);
}
// Validate and refresh session if needed
validateSession(telegramId) {
const session = this.userSessions.get(telegramId);
if (!session || !session.user || !session.user.id) {
return false;
}
// Check if session is too old (optional - 24 hours)
if (session.loginTime) {
const loginTime = new Date(session.loginTime);
const now = new Date();
const hoursDiff = (now - loginTime) / (1000 * 60 * 60);
if (hoursDiff > 24) {
console.log('Session expired for user:', telegramId);
this.userSessions.delete(telegramId);
this.api.setUserToken(telegramId, null);
return false;
}
}
return true;
}
// Get session info for debugging
getSessionInfo(telegramId) {
const session = this.userSessions.get(telegramId);
if (!session) {
return { exists: false };
}
return {
exists: true,
hasUser: !!session.user,
userId: session.user?.id,
userName: session.user?.name,
phone: session.phoneNumber,
loginTime: session.loginTime
};
}
isAuthenticated(telegramId) {
return this.validateSession(telegramId);
}
getUser(telegramId) {
if (!this.validateSession(telegramId)) {
return null;
}
const session = this.userSessions.get(telegramId);
return session ? session.user : null;
}
// Generate a consistent password based on phone number
generateUserPassword(phoneNumber) {
// Create a consistent password based on phone number
// This ensures the same password is generated for the same phone number
const crypto = require('crypto');
return crypto.createHash('md5').update(phoneNumber + 'yaltipia_salt').digest('hex').substring(0, 12);
}
}
module.exports = AuthFeature;

35
src/features/menu.js Normal file
View File

@ -0,0 +1,35 @@
class MenuFeature {
constructor(bot, userStates) {
this.bot = bot;
this.userStates = userStates;
}
showMainMenu(chatId, telegramId = null) {
// If telegramId is provided, we should validate session in auth feature
// For now, just show the menu
const websiteUrl = process.env.WEBSITE_URL || 'https://yaltipia.com/listings';
const keyboard = {
inline_keyboard: [
[{ text: '🔔 Create Notification', callback_data: 'create_notification' }],
[{ text: '📋 View My Notifications', callback_data: 'view_notifications' }],
[{ text: '🌐 Browse All Listings', url: websiteUrl }],
[{ text: '🚪 Logout', callback_data: 'logout' }]
]
};
this.bot.sendMessage(chatId,
'🏠 Yaltipia Home Bot - Main Menu\n\n' +
'What would you like to do?',
{ reply_markup: keyboard }
);
}
async handleBackToMenu(chatId, telegramId) {
this.userStates.delete(telegramId);
this.showMainMenu(chatId);
return true;
}
}
module.exports = MenuFeature;

View File

@ -0,0 +1,736 @@
class NotificationFeature {
constructor(bot, api, userStates, userSessions, notificationService) {
this.bot = bot;
this.api = api;
this.userStates = userStates;
this.userSessions = userSessions;
this.notificationService = notificationService;
}
async startNotificationCreation(chatId, telegramId) {
const userSession = this.userSessions.get(telegramId);
if (!userSession || !userSession.user) {
this.bot.sendMessage(chatId, 'Please register first by sending /start');
return;
}
const userState = this.userStates.get(telegramId) || {};
userState.step = 'notification_name';
userState.notificationData = {};
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'🔔 Create New Property Notification\n\n' +
'Please enter a name for this notification (e.g., "Downtown Apartment"):'
);
}
async handleNotificationText(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
const userState = this.userStates.get(telegramId);
if (!userState || !userState.step?.startsWith('notification_')) {
return false;
}
try {
switch (userState.step) {
case 'notification_name':
userState.notificationData.name = msg.text.trim();
this.askPropertyType(chatId, telegramId);
return true;
case 'notification_subcity':
const subcityText = msg.text.trim().toLowerCase();
if (subcityText === 'any' || subcityText === '') {
userState.notificationData.subcity = null;
} else {
userState.notificationData.subcity = msg.text.trim();
}
this.askHouseType(chatId, telegramId);
return true;
case 'notification_house_type':
const houseTypeText = msg.text.trim().toLowerCase();
if (houseTypeText === 'any' || houseTypeText === '') {
userState.notificationData.houseType = null;
} else {
userState.notificationData.houseType = msg.text.trim();
}
this.askMinPrice(chatId, telegramId);
return true;
case 'notification_min_price':
const minPriceText = msg.text.trim().toLowerCase();
if (minPriceText === 'skip' || minPriceText === '0' || minPriceText === '') {
userState.notificationData.minPrice = null;
} else {
const minPrice = parseInt(msg.text.trim());
if (isNaN(minPrice)) {
this.bot.sendMessage(chatId, 'Please enter a valid number, "0", or "skip":');
return true;
}
userState.notificationData.minPrice = minPrice;
}
this.askMaxPrice(chatId, telegramId);
return true;
case 'notification_max_price':
const maxPriceText = msg.text.trim().toLowerCase();
if (maxPriceText === 'skip' || maxPriceText === '0' || maxPriceText === '') {
userState.notificationData.maxPrice = null;
} else {
const maxPrice = parseInt(msg.text.trim());
if (isNaN(maxPrice)) {
this.bot.sendMessage(chatId, 'Please enter a valid number, "0", or "skip":');
return true;
}
userState.notificationData.maxPrice = maxPrice;
}
await this.showNotificationSummary(chatId, telegramId, userState);
return true;
}
} catch (error) {
console.error('Error in handleNotificationText:', error);
this.bot.sendMessage(chatId, 'Sorry, something went wrong. Please try again.');
}
return false;
}
askPropertyType(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
userState.step = 'notification_type';
this.userStates.set(telegramId, userState);
const keyboard = {
inline_keyboard: [
[{ text: '🏠 Rent', callback_data: 'type_RENT' }],
[{ text: '💰 Sell', callback_data: 'type_SELL' }]
]
};
this.bot.sendMessage(chatId,
'What type of property are you looking for?',
{ reply_markup: keyboard }
);
}
async setNotificationType(chatId, telegramId, type) {
const userState = this.userStates.get(telegramId);
if (!userState || userState.step !== 'notification_type') {
return false;
}
userState.notificationData.type = type;
userState.step = 'notification_status';
this.userStates.set(telegramId, userState);
const keyboard = {
inline_keyboard: [
[{ text: '📝 Draft', callback_data: 'status_DRAFT' }],
[{ text: '✅ Active', callback_data: 'status_ACTIVE' }],
[{ text: '🏠 Rented', callback_data: 'status_RENTED' }],
[{ text: '💰 For Sale', callback_data: 'status_FOR_SALE' }],
[{ text: '✔️ Sold', callback_data: 'status_SOLD' }],
[{ text: '❌ Inactive', callback_data: 'status_INACTIVE' }]
]
};
this.bot.sendMessage(chatId,
'What status are you interested in?',
{ reply_markup: keyboard }
);
return true;
}
async setNotificationStatus(chatId, telegramId, status) {
const userState = this.userStates.get(telegramId);
if (!userState || userState.step !== 'notification_status') {
return false;
}
userState.notificationData.status = status;
userState.step = 'notification_subcity';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'Please enter the subcity/area you\'re interested in:\n\n' +
'💡 Examples: Bole, Kirkos, Addis Ketema\n' +
'💡 Tip: Send "any" to match all areas'
);
return true;
}
askHouseType(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
userState.step = 'notification_house_type';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'Please enter the house type:\n\n' +
'💡 Examples: Apartment, Villa, Studio, Condominium\n' +
'💡 Tip: Send "any" to match all house types'
);
}
askMinPrice(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
userState.step = 'notification_min_price';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'Please enter the minimum price:\n\n' +
'💡 Tip: Leave empty (send "0" or "skip") for no minimum limit'
);
}
askMaxPrice(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
userState.step = 'notification_max_price';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'Please enter the maximum price:\n\n' +
'💡 Tip: Leave empty (send "0" or "skip") for no maximum limit'
);
}
async showNotificationSummary(chatId, telegramId, userState) {
const data = userState.notificationData;
const summaryMessage =
'📋 Notification Summary\n\n' +
`📝 Name: ${data.name}\n` +
`🏠 Type: ${data.type}\n` +
`📊 Status: ${data.status}\n` +
`📍 Area: ${data.subcity || 'Any area'}\n` +
`🏡 House Type: ${data.houseType || 'Any type'}\n` +
`💰 Price Range: ${data.minPrice || 'No min'} - ${data.maxPrice || 'No max'}\n\n` +
'What would you like to do?';
const keyboard = {
inline_keyboard: [
[{ text: '🔍 Preview Matching Listings', callback_data: 'preview_matches' }],
[{ text: '✅ Save Notification', callback_data: 'save_notification' }],
[{ text: '🔙 Back to Main Menu', callback_data: 'back_to_menu' }]
]
};
this.bot.sendMessage(chatId, summaryMessage, { reply_markup: keyboard });
}
async saveNotification(chatId, telegramId) {
console.log('saveNotification called for user:', telegramId);
const userState = this.userStates.get(telegramId);
const userSession = this.userSessions.get(telegramId);
console.log('User state exists:', !!userState);
console.log('User session exists:', !!userSession);
console.log('Notification service exists:', !!this.notificationService);
if (!userState || !userState.notificationData) {
this.bot.sendMessage(chatId, 'No notification data found.');
return false;
}
if (!userSession || !userSession.user) {
this.bot.sendMessage(chatId, 'Session expired. Please start again with /start');
return false;
}
try {
console.log('Creating notification with data:', userState.notificationData);
// Use backend API instead of local service
const notificationResult = await this.notificationService.createNotification(
userSession.user.id,
userState.notificationData,
telegramId // Pass telegramId for API authentication
);
console.log('Notification result:', notificationResult);
if (!notificationResult.success) {
this.bot.sendMessage(chatId,
`❌ Failed to create notification: ${notificationResult.error}`
);
return false;
}
this.userStates.delete(telegramId);
const data = userState.notificationData;
this.bot.sendMessage(chatId,
'✅ Notification created successfully!\n\n' +
`📝 Name: ${data.name}\n` +
`🏠 Type: ${data.type}\n` +
`📊 Status: ${data.status}\n` +
`📍 Area: ${data.subcity || 'Any area'}\n` +
`🏡 House Type: ${data.houseType || 'Any type'}\n` +
`💰 Price Range: ${data.minPrice || 'No min'} - ${data.maxPrice || 'No max'}\n\n` +
'You will receive notifications when matching properties are listed!'
);
return true;
} catch (error) {
console.error('Error saving notification:', error);
this.bot.sendMessage(chatId, 'Sorry, failed to save notification. Please try again.');
return false;
}
}
async showUserNotifications(chatId, telegramId) {
const userSession = this.userSessions.get(telegramId);
if (!userSession || !userSession.user) {
this.bot.sendMessage(chatId, 'Session expired. Please start again with /start');
return;
}
try {
// Use backend API instead of local service
const notificationsResult = await this.notificationService.getUserNotifications(userSession.user.id, telegramId);
if (!notificationsResult.success) {
this.bot.sendMessage(chatId,
`❌ Failed to load notifications: ${notificationsResult.error}`
);
return;
}
const notifications = notificationsResult.notifications;
console.log('Notifications received:', notifications);
console.log('Is notifications an array?', Array.isArray(notifications));
console.log('Notifications type:', typeof notifications);
if (!notifications || !Array.isArray(notifications) || notifications.length === 0) {
this.bot.sendMessage(chatId,
'📋 You don\'t have any active notifications yet.\n\n' +
'Create your first notification to start receiving property updates!'
);
return;
}
// Show notifications with management buttons
let message = '📋 Your Active Notifications:\n\n';
notifications.forEach((notif, index) => {
message += `${index + 1}. 📝 ${notif.name || notif.type}\n`;
message += ` 📊 Status: ${notif.status}\n`;
message += ` 📍 Area: ${notif.subcity || 'Any'}\n`;
message += ` 🏡 Type: ${notif.houseType || 'Any'}\n`;
message += ` 💰 Price: ${notif.minPrice || 0} - ${notif.maxPrice || '∞'}\n\n`;
});
// Create inline keyboard with management options for each notification
const keyboard = {
inline_keyboard: []
};
// Add buttons for each notification
notifications.forEach((notif, index) => {
keyboard.inline_keyboard.push([
{
text: `📝 Edit "${notif.name}"`,
callback_data: `edit_notification_${notif.id}`
},
{
text: `🗑️ Delete "${notif.name}"`,
callback_data: `delete_notification_${notif.id}`
}
]);
});
// Add general action buttons
keyboard.inline_keyboard.push([
{ text: '🔔 Create New Notification', callback_data: 'create_notification' }
]);
keyboard.inline_keyboard.push([
{ text: '🔙 Back to Main Menu', callback_data: 'back_to_menu' }
]);
this.bot.sendMessage(chatId, message, { reply_markup: keyboard });
} catch (error) {
console.error('Error showing notifications:', error);
this.bot.sendMessage(chatId, 'Sorry, failed to load notifications.');
}
}
async previewMatches(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
if (!userState || !userState.notificationData) {
this.bot.sendMessage(chatId, 'No notification data found. Please create a notification first.');
return false;
}
try {
this.bot.sendMessage(chatId, '🔍 Checking for matching listings...');
// Use the notification service to check matches
const matchResult = await this.notificationService.checkMatches(telegramId, userState.notificationData);
if (!matchResult.success) {
this.bot.sendMessage(chatId,
`❌ Preview failed: ${matchResult.error}`
);
return false;
}
const matches = matchResult.listings;
if (!matches || matches.length === 0) {
const keyboard = {
inline_keyboard: [
[{ text: '✅ Save Notification Anyway', callback_data: 'save_notification' }],
[{ text: '🌐 Browse All Listings', url: process.env.WEBSITE_URL || 'https://yaltipia.com/listings' }],
[{ text: '🔙 Back to Main Menu', callback_data: 'back_to_menu' }]
]
};
this.bot.sendMessage(chatId,
'🔍 No current listings match your criteria.\n\n' +
'You can still save this notification to get alerts when matching properties are listed, or browse all listings on our website.',
{ reply_markup: keyboard }
);
return true;
}
let message = `🎯 Found ${matches.length} matching listings!\n\n`;
// Show first 3 matches
const displayMatches = matches.slice(0, 3);
displayMatches.forEach((listing, index) => {
message += `${index + 1}. 🏠 ${listing.title || 'Property'}\n`;
message += ` 📊 Status: ${listing.status}\n`;
message += ` 📍 Location: ${listing.subcity || 'N/A'}\n`;
message += ` 🏡 Type: ${listing.houseType || 'N/A'}\n`;
message += ` 💰 Price: ${listing.price || 'N/A'}\n\n`;
});
if (matches.length > 3) {
message += `... and ${matches.length - 3} more matches\n\n`;
}
const keyboard = {
inline_keyboard: [
[{ text: '✅ Save Notification', callback_data: 'save_notification' }],
[{ text: '🔙 Back to Main Menu', callback_data: 'back_to_menu' }]
]
};
this.bot.sendMessage(chatId, message, { reply_markup: keyboard });
return true;
} catch (error) {
console.error('Error previewing matches:', error);
this.bot.sendMessage(chatId, 'Sorry, failed to preview matches. Please try again.');
return false;
}
}
async deleteNotification(chatId, telegramId, notificationId) {
const userSession = this.userSessions.get(telegramId);
if (!userSession || !userSession.user) {
this.bot.sendMessage(chatId, 'Session expired. Please start again with /start');
return false;
}
try {
// Show confirmation dialog
const keyboard = {
inline_keyboard: [
[
{ text: '✅ Yes, Delete', callback_data: `confirm_delete_${notificationId}` },
{ text: '❌ Cancel', callback_data: 'view_notifications' }
]
]
};
this.bot.sendMessage(chatId,
'⚠️ Are you sure you want to delete this notification?\n\n' +
'This action cannot be undone.',
{ reply_markup: keyboard }
);
return true;
} catch (error) {
console.error('Error showing delete confirmation:', error);
this.bot.sendMessage(chatId, 'Sorry, failed to show delete confirmation.');
return false;
}
}
async confirmDeleteNotification(chatId, telegramId, notificationId) {
const userSession = this.userSessions.get(telegramId);
if (!userSession || !userSession.user) {
this.bot.sendMessage(chatId, 'Session expired. Please start again with /start');
return false;
}
try {
console.log('Deleting notification:', notificationId);
const deleteResult = await this.notificationService.deleteNotification(notificationId, telegramId);
if (!deleteResult.success) {
this.bot.sendMessage(chatId,
`❌ Failed to delete notification: ${deleteResult.error}`
);
return false;
}
this.bot.sendMessage(chatId, '✅ Notification deleted successfully!');
// Show updated notifications list
await this.showUserNotifications(chatId, telegramId);
return true;
} catch (error) {
console.error('Error deleting notification:', error);
this.bot.sendMessage(chatId, 'Sorry, failed to delete notification.');
return false;
}
}
async editNotification(chatId, telegramId, notificationId) {
const userSession = this.userSessions.get(telegramId);
if (!userSession || !userSession.user) {
this.bot.sendMessage(chatId, 'Session expired. Please start again with /start');
return false;
}
try {
// Get the specific notification details first
// For now, we'll show edit options - you can implement getNotificationById API later
const keyboard = {
inline_keyboard: [
[{ text: '📝 Edit Name', callback_data: `edit_name_${notificationId}` }],
[{ text: '🏠 Edit Type', callback_data: `edit_type_${notificationId}` }],
[{ text: '📊 Edit Status', callback_data: `edit_status_${notificationId}` }],
[{ text: '📍 Edit Area', callback_data: `edit_area_${notificationId}` }],
[{ text: '🏡 Edit House Type', callback_data: `edit_house_type_${notificationId}` }],
[{ text: '💰 Edit Price Range', callback_data: `edit_price_${notificationId}` }],
[{ text: '🔙 Back to Notifications', callback_data: 'view_notifications' }]
]
};
this.bot.sendMessage(chatId,
'✏️ What would you like to edit?\n\n' +
'Choose an option below:',
{ reply_markup: keyboard }
);
return true;
} catch (error) {
console.error('Error showing edit options:', error);
this.bot.sendMessage(chatId, 'Sorry, failed to show edit options.');
return false;
}
}
async startEditField(chatId, telegramId, notificationId, field) {
const userState = this.userStates.get(telegramId) || {};
userState.step = `edit_${field}`;
userState.editingNotificationId = notificationId;
userState.editingField = field;
this.userStates.set(telegramId, userState);
let message = '';
let keyboard = null;
switch (field) {
case 'name':
message = '📝 Enter the new name for this notification:';
break;
case 'type':
message = '🏠 Select the new property type:';
keyboard = {
inline_keyboard: [
[{ text: '🏠 Rent', callback_data: `update_type_RENT_${notificationId}` }],
[{ text: '💰 Sell', callback_data: `update_type_SELL_${notificationId}` }],
[{ text: '🔙 Back to Edit Menu', callback_data: `edit_notification_${notificationId}` }]
]
};
break;
case 'status':
message = '📊 Select the new status:';
keyboard = {
inline_keyboard: [
[{ text: '📝 Draft', callback_data: `update_status_DRAFT_${notificationId}` }],
[{ text: '✅ Active', callback_data: `update_status_ACTIVE_${notificationId}` }],
[{ text: '🏠 Rented', callback_data: `update_status_RENTED_${notificationId}` }],
[{ text: '💰 For Sale', callback_data: `update_status_FOR_SALE_${notificationId}` }],
[{ text: '✔️ Sold', callback_data: `update_status_SOLD_${notificationId}` }],
[{ text: '❌ Inactive', callback_data: `update_status_INACTIVE_${notificationId}` }],
[{ text: '🔙 Back to Edit Menu', callback_data: `edit_notification_${notificationId}` }]
]
};
break;
case 'area':
message = '📍 Enter the new area/subcity:\n\n💡 Tip: Send "any" to match all areas';
break;
case 'house_type':
message = '🏡 Enter the new house type:\n\n💡 Examples: Apartment, Villa, Studio\n💡 Tip: Send "any" to match all types';
break;
case 'price':
message = '💰 Enter the new minimum price:\n\n💡 Tip: Send "0" or "skip" for no minimum';
userState.step = 'edit_min_price';
break;
default:
message = `Enter the new ${field}:`;
}
if (keyboard) {
this.bot.sendMessage(chatId, message, { reply_markup: keyboard });
} else {
this.bot.sendMessage(chatId, message);
}
return true;
}
async updateNotificationDirectly(chatId, telegramId, notificationId, updateData) {
try {
console.log('Updating notification directly:', notificationId, updateData);
const updateResult = await this.api.updateNotification(telegramId, notificationId, updateData);
if (!updateResult.success) {
this.bot.sendMessage(chatId,
`❌ Failed to update notification: ${updateResult.error}`
);
return false;
}
this.userStates.delete(telegramId);
this.bot.sendMessage(chatId, '✅ Notification updated successfully!');
// Show updated notifications list
await this.showUserNotifications(chatId, telegramId);
return true;
} catch (error) {
console.error('Error updating notification directly:', error);
this.bot.sendMessage(chatId,
`❌ Failed to update notification: ${error.message}`
);
return false;
}
}
async handleEditText(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
const userState = this.userStates.get(telegramId);
if (!userState || !userState.step?.startsWith('edit_')) {
return false;
}
try {
const notificationId = userState.editingNotificationId;
const newValue = msg.text.trim();
// Handle different field types
let updateData = {};
switch (userState.step) {
case 'edit_name':
updateData.name = newValue;
await this.updateNotificationField(chatId, telegramId, notificationId, updateData);
return true;
case 'edit_area':
updateData.subcity = newValue.toLowerCase() === 'any' ? null : newValue;
await this.updateNotificationField(chatId, telegramId, notificationId, updateData);
return true;
case 'edit_house_type':
updateData.houseType = newValue.toLowerCase() === 'any' ? null : newValue;
await this.updateNotificationField(chatId, telegramId, notificationId, updateData);
return true;
case 'edit_min_price':
const minPriceText = newValue.toLowerCase();
if (minPriceText === 'skip' || minPriceText === '0') {
userState.editMinPrice = null;
} else {
const minPrice = parseInt(newValue);
if (isNaN(minPrice)) {
this.bot.sendMessage(chatId, 'Please enter a valid number, "0", or "skip":');
return true;
}
userState.editMinPrice = minPrice;
}
userState.step = 'edit_max_price';
this.bot.sendMessage(chatId, '💰 Enter the new maximum price:\n\n💡 Tip: Send "0" or "skip" for no maximum');
return true;
case 'edit_max_price':
const maxPriceText = newValue.toLowerCase();
let maxPrice = null;
if (maxPriceText !== 'skip' && maxPriceText !== '0') {
maxPrice = parseInt(newValue);
if (isNaN(maxPrice)) {
this.bot.sendMessage(chatId, 'Please enter a valid number, "0", or "skip":');
return true;
}
}
updateData.minPrice = userState.editMinPrice;
updateData.maxPrice = maxPrice;
await this.updateNotificationField(chatId, telegramId, notificationId, updateData);
return true;
}
return false;
} catch (error) {
console.error('Error handling edit text:', error);
this.bot.sendMessage(chatId, 'Sorry, something went wrong while editing.');
return false;
}
}
async updateNotificationField(chatId, telegramId, notificationId, updateData) {
try {
console.log('Updating notification field:', notificationId, updateData);
// Use the API client method instead of direct axios call
const updateResult = await this.api.updateNotification(telegramId, notificationId, updateData);
if (!updateResult.success) {
this.bot.sendMessage(chatId,
`❌ Failed to update notification: ${updateResult.error}`
);
return;
}
this.userStates.delete(telegramId);
this.bot.sendMessage(chatId, '✅ Notification updated successfully!');
// Show updated notifications list
await this.showUserNotifications(chatId, telegramId);
} catch (error) {
console.error('Error updating notification:', error);
this.bot.sendMessage(chatId,
`❌ Failed to update notification: ${error.message}`
);
}
}
}
module.exports = NotificationFeature;

241
src/features/search.js Normal file
View File

@ -0,0 +1,241 @@
class SearchFeature {
constructor(bot, api, userStates, userSessions) {
this.bot = bot;
this.api = api;
this.userStates = userStates;
this.userSessions = userSessions;
}
async startListingSearch(chatId, telegramId) {
const userSession = this.userSessions.get(telegramId);
if (!userSession || !userSession.user) {
this.bot.sendMessage(chatId, 'Please register first by sending /start');
return;
}
const userState = this.userStates.get(telegramId) || {};
userState.step = 'search_name';
userState.searchData = {};
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'🔍 Search Current Listings\n\n' +
'Please enter a name for this search (e.g., "Downtown Search"):'
);
}
async handleSearchText(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
const userState = this.userStates.get(telegramId);
if (!userState || !userState.step?.startsWith('search_')) {
return false;
}
try {
switch (userState.step) {
case 'search_name':
userState.searchData.name = msg.text.trim();
this.askSearchPropertyType(chatId, telegramId);
return true;
case 'search_subcity':
userState.searchData.subcity = msg.text.trim();
this.askSearchHouseType(chatId, telegramId);
return true;
case 'search_house_type':
userState.searchData.houseType = msg.text.trim();
this.askSearchMinPrice(chatId, telegramId);
return true;
case 'search_min_price':
const searchMinPrice = parseInt(msg.text.trim());
if (isNaN(searchMinPrice)) {
this.bot.sendMessage(chatId, 'Please enter a valid number for minimum price:');
return true;
}
userState.searchData.minPrice = searchMinPrice;
this.askSearchMaxPrice(chatId, telegramId);
return true;
case 'search_max_price':
const searchMaxPrice = parseInt(msg.text.trim());
if (isNaN(searchMaxPrice)) {
this.bot.sendMessage(chatId, 'Please enter a valid number for maximum price:');
return true;
}
userState.searchData.maxPrice = searchMaxPrice;
await this.executeSearch(chatId, telegramId, userState);
return true;
}
} catch (error) {
console.error('Error in handleSearchText:', error);
this.bot.sendMessage(chatId, 'Sorry, something went wrong. Please try again.');
}
return false;
}
askSearchPropertyType(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
userState.step = 'search_type';
this.userStates.set(telegramId, userState);
const keyboard = {
inline_keyboard: [
[{ text: '🏠 Rent', callback_data: 'type_RENT' }],
[{ text: '💰 Sell', callback_data: 'type_SELL' }],
[{ text: '🔄 Any', callback_data: 'type_ANY' }]
]
};
this.bot.sendMessage(chatId,
'What type of property are you looking for?',
{ reply_markup: keyboard }
);
}
async setSearchType(chatId, telegramId, type) {
const userState = this.userStates.get(telegramId);
if (!userState || userState.step !== 'search_type') {
return false;
}
userState.searchData.type = type === 'ANY' ? null : type;
userState.step = 'search_status';
this.userStates.set(telegramId, userState);
const keyboard = {
inline_keyboard: [
[{ text: '📝 Draft', callback_data: 'status_DRAFT' }],
[{ text: '✅ Active', callback_data: 'status_ACTIVE' }],
[{ text: '🏠 Rented', callback_data: 'status_RENTED' }],
[{ text: '💰 For Sale', callback_data: 'status_FOR_SALE' }],
[{ text: '✔️ Sold', callback_data: 'status_SOLD' }],
[{ text: '❌ Inactive', callback_data: 'status_INACTIVE' }],
[{ text: '🔄 Any', callback_data: 'status_ANY' }]
]
};
this.bot.sendMessage(chatId,
'What status are you interested in?',
{ reply_markup: keyboard }
);
return true;
}
async setSearchStatus(chatId, telegramId, status) {
const userState = this.userStates.get(telegramId);
if (!userState || userState.step !== 'search_status') {
return false;
}
userState.searchData.status = status === 'ANY' ? null : status;
userState.step = 'search_subcity';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'Please enter the subcity/area you\'re interested in (or type "any" for all areas):'
);
return true;
}
askSearchHouseType(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
userState.step = 'search_house_type';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'Please enter the house type (e.g., Apartment, Villa, Studio) or type "any" for all types:'
);
}
askSearchMinPrice(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
userState.step = 'search_min_price';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'Please enter the minimum price (or 0 for no minimum):'
);
}
askSearchMaxPrice(chatId, telegramId) {
const userState = this.userStates.get(telegramId);
userState.step = 'search_max_price';
this.userStates.set(telegramId, userState);
this.bot.sendMessage(chatId,
'Please enter the maximum price (or 0 for no maximum):'
);
}
async executeSearch(chatId, telegramId, userState) {
try {
this.bot.sendMessage(chatId, '🔍 Searching for listings...');
const searchFilters = {
type: userState.searchData.type,
status: userState.searchData.status,
minPrice: userState.searchData.minPrice || undefined,
maxPrice: userState.searchData.maxPrice || undefined,
subcity: userState.searchData.subcity === 'any' ? undefined : userState.searchData.subcity,
houseType: userState.searchData.houseType === 'any' ? undefined : userState.searchData.houseType
};
const searchResult = await this.api.getListings(searchFilters);
if (!searchResult.success) {
this.bot.sendMessage(chatId,
`❌ Search failed: ${searchResult.error}`
);
return;
}
this.userStates.delete(telegramId);
await this.displaySearchResults(chatId, searchResult.listings, userState.searchData);
} catch (error) {
console.error('Error executing search:', error);
this.bot.sendMessage(chatId, 'Sorry, search failed. Please try again.');
}
}
async displaySearchResults(chatId, listings, searchData) {
if (!listings || listings.length === 0) {
this.bot.sendMessage(chatId,
'🔍 No listings found matching your criteria.\n\n' +
'Try adjusting your search filters.'
);
return;
}
let message = `🔍 Search Results (${listings.length} found)\n\n`;
// Show first 5 results
const displayListings = listings.slice(0, 5);
displayListings.forEach((listing, index) => {
message += `${index + 1}. 🏠 ${listing.title || 'Property'}\n`;
message += ` 📊 Status: ${listing.status}\n`;
message += ` 📍 Location: ${listing.subcity || 'N/A'}\n`;
message += ` 🏡 Type: ${listing.houseType || 'N/A'}\n`;
message += ` 💰 Price: ${listing.price || 'N/A'}\n`;
if (listing.description) {
message += ` 📝 ${listing.description.substring(0, 50)}...\n`;
}
message += '\n';
});
if (listings.length > 5) {
message += `... and ${listings.length - 5} more results\n\n`;
}
this.bot.sendMessage(chatId, message);
}
}
module.exports = SearchFeature;

View File

@ -0,0 +1,64 @@
class NotificationService {
constructor(api) {
this.api = api;
// Remove local storage - now using backend API
}
// Create a new notification via API
createNotification(userId, notificationData, telegramId) {
console.log('Creating notification via backend API');
return this.api.createNotification(telegramId, userId, notificationData);
}
// Get user notifications via API
getUserNotifications(userId, telegramId) {
console.log('Getting user notifications via backend API');
return this.api.getUserNotifications(telegramId, userId);
}
// Delete a notification via API
deleteNotification(notificationId, telegramId) {
console.log('Deleting notification via backend API');
return this.api.deleteNotification(telegramId, notificationId);
}
// Check for matching listings for a specific notification
async checkMatches(telegramId, notificationData) {
try {
// For preview, we still use the listings API with filters
const filters = {
type: notificationData.type,
status: notificationData.status,
minPrice: notificationData.minPrice,
maxPrice: notificationData.maxPrice,
subcity: notificationData.subcity,
houseType: notificationData.houseType
};
return await this.api.getListings(telegramId, filters);
} catch (error) {
console.error('Error checking matches for notification:', error);
return { success: false, error: error.message };
}
}
// Check matches for existing notification by ID
async checkNotificationMatches(telegramId, notificationId) {
console.log('Getting notification matches via backend API');
return this.api.getNotificationMatches(telegramId, notificationId);
}
// Get notifications by telegram user ID (public endpoint)
async getNotificationsByTelegramId(telegramUserId) {
console.log('Getting notifications by telegram ID via backend API');
return this.api.getNotificationsByTelegramId(telegramUserId);
}
// Clear user notifications (for logout) - now just a placeholder
clearUserNotifications(userId) {
console.log('User logged out - notifications remain in backend');
// Notifications persist in backend, no need to clear
}
}
module.exports = NotificationService;

89
src/utils/envValidator.js Normal file
View File

@ -0,0 +1,89 @@
class EnvironmentValidator {
static validateEnvironment() {
const requiredEnvVars = [
'TELEGRAM_BOT_TOKEN',
'API_BASE_URL'
];
const missingVars = [];
const invalidVars = [];
for (const envVar of requiredEnvVars) {
const value = process.env[envVar];
if (!value) {
missingVars.push(envVar);
continue;
}
// Validate specific environment variables
switch (envVar) {
case 'TELEGRAM_BOT_TOKEN':
if (!this.validateTelegramToken(value)) {
invalidVars.push(`${envVar}: Invalid token format`);
}
break;
case 'API_BASE_URL':
if (!this.validateApiUrl(value)) {
invalidVars.push(`${envVar}: Invalid URL format`);
}
break;
}
}
if (missingVars.length > 0) {
console.error('❌ Missing required environment variables:');
missingVars.forEach(varName => {
console.error(` - ${varName}`);
});
console.error('\nPlease check your .env file and ensure all required variables are set.');
return false;
}
if (invalidVars.length > 0) {
console.error('❌ Invalid environment variables:');
invalidVars.forEach(error => {
console.error(` - ${error}`);
});
return false;
}
console.log('✅ Environment validation passed');
return true;
}
static validateTelegramToken(token) {
// Telegram bot tokens have a specific format: number:string
const tokenRegex = /^\d+:[A-Za-z0-9_-]{35}$/;
return tokenRegex.test(token);
}
static validateApiUrl(url) {
try {
const parsedUrl = new URL(url);
// Ensure HTTPS in production (allow HTTP for development)
if (process.env.NODE_ENV === 'production' && parsedUrl.protocol !== 'https:') {
console.warn('⚠️ WARNING: Using HTTP in production is not secure');
return false;
}
return ['http:', 'https:'].includes(parsedUrl.protocol);
} catch (error) {
return false;
}
}
static getSecureConfig() {
return {
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
apiBaseUrl: process.env.API_BASE_URL,
websiteUrl: process.env.WEBSITE_URL || 'https://yaltipia.com/listings',
nodeEnv: process.env.NODE_ENV || 'development',
isProduction: process.env.NODE_ENV === 'production'
};
}
}
module.exports = EnvironmentValidator;

99
src/utils/errorHandler.js Normal file
View File

@ -0,0 +1,99 @@
class ErrorHandler {
static getUserFriendlyMessage(error, operation = 'operation') {
// Don't expose technical details to users
const friendlyMessages = {
// Network/Connection errors
'ECONNREFUSED': 'Service temporarily unavailable. Please try again later.',
'ETIMEDOUT': 'Request timed out. Please check your connection and try again.',
'ENOTFOUND': 'Service unavailable. Please try again later.',
'ECONNRESET': 'Connection lost. Please try again.',
// HTTP Status codes
400: 'Invalid request. Please check your input and try again.',
401: 'Authentication failed. Please login again with /start',
403: 'Access denied. You don\'t have permission for this action.',
404: 'Resource not found. The item you\'re looking for doesn\'t exist.',
409: 'Conflict occurred. The item might already exist.',
422: 'Invalid data provided. Please check your input.',
429: 'Too many requests. Please wait a moment and try again.',
500: 'Server error occurred. Please try again later.',
502: 'Service temporarily unavailable. Please try again later.',
503: 'Service maintenance in progress. Please try again later.',
504: 'Request timed out. Please try again later.'
};
// Operation-specific messages
const operationMessages = {
'create_notification': 'Failed to create notification',
'get_notifications': 'Failed to load notifications',
'update_notification': 'Failed to update notification',
'delete_notification': 'Failed to delete notification',
'get_matches': 'Failed to find matching listings',
'login': 'Login failed',
'register': 'Registration failed',
'phone_check': 'Failed to verify phone number'
};
let message = operationMessages[operation] || `Failed to complete ${operation}`;
// Check for specific error types
if (error.code && friendlyMessages[error.code]) {
return `${message}: ${friendlyMessages[error.code]}`;
}
if (error.response?.status && friendlyMessages[error.response.status]) {
return `${message}: ${friendlyMessages[error.response.status]}`;
}
// Check for common error patterns
if (error.message) {
const msg = error.message.toLowerCase();
if (msg.includes('network') || msg.includes('connection')) {
return `${message}: Connection problem. Please check your internet and try again.`;
}
if (msg.includes('timeout')) {
return `${message}: Request timed out. Please try again.`;
}
if (msg.includes('unauthorized') || msg.includes('authentication')) {
return `${message}: Please login again with /start`;
}
if (msg.includes('not found')) {
return `${message}: Item not found.`;
}
if (msg.includes('already exists')) {
return `${message}: Item already exists.`;
}
}
// Generic fallback message
return `${message}. Please try again or contact support if the problem persists.`;
}
static logError(error, context = '') {
// Log technical details for developers (not shown to users)
console.error(`[ERROR] ${context}:`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
code: error.code,
stack: error.stack
});
}
static handleApiError(error, operation, chatId, bot) {
// Log technical details
this.logError(error, operation);
// Send user-friendly message
const userMessage = this.getUserFriendlyMessage(error, operation);
bot.sendMessage(chatId, `${userMessage}`);
}
}
module.exports = ErrorHandler;

144
src/utils/inputValidator.js Normal file
View File

@ -0,0 +1,144 @@
const validator = require('validator');
class InputValidator {
static sanitizeText(input) {
if (!input || typeof input !== 'string') {
return '';
}
// Remove dangerous characters and trim
return validator.escape(input.trim());
}
static validateEmail(email) {
if (!email || typeof email !== 'string') {
return { valid: false, message: 'Email is required' };
}
const trimmedEmail = email.trim().toLowerCase();
if (!validator.isEmail(trimmedEmail)) {
return { valid: false, message: 'Please enter a valid email address' };
}
if (trimmedEmail.length > 254) {
return { valid: false, message: 'Email address is too long' };
}
return { valid: true, email: trimmedEmail };
}
static validatePhone(phone) {
if (!phone || typeof phone !== 'string') {
return { valid: false, message: 'Phone number is required' };
}
const cleanPhone = phone.replace(/\s+/g, '');
// Basic phone validation (international format)
if (!validator.isMobilePhone(cleanPhone, 'any', { strictMode: false })) {
return { valid: false, message: 'Please enter a valid phone number' };
}
return { valid: true, phone: cleanPhone };
}
static validateName(name) {
if (!name || typeof name !== 'string') {
return { valid: false, message: 'Name is required' };
}
const trimmedName = name.trim();
if (trimmedName.length < 1) {
return { valid: false, message: 'Name cannot be empty' };
}
if (trimmedName.length > 100) {
return { valid: false, message: 'Name is too long (max 100 characters)' };
}
// Check for potentially dangerous characters
if (/<script|javascript:|data:|vbscript:/i.test(trimmedName)) {
return { valid: false, message: 'Name contains invalid characters' };
}
return { valid: true, name: this.sanitizeText(trimmedName) };
}
static validateNotificationName(name) {
if (!name || typeof name !== 'string') {
return { valid: false, message: 'Notification name is required' };
}
const trimmedName = name.trim();
if (trimmedName.length < 1) {
return { valid: false, message: 'Notification name cannot be empty' };
}
if (trimmedName.length > 255) {
return { valid: false, message: 'Notification name is too long (max 255 characters)' };
}
return { valid: true, name: this.sanitizeText(trimmedName) };
}
static validatePrice(price) {
if (price === null || price === undefined || price === '') {
return { valid: true, price: null }; // Allow null prices
}
const numPrice = parseInt(price);
if (isNaN(numPrice)) {
return { valid: false, message: 'Please enter a valid number' };
}
if (numPrice < 0) {
return { valid: false, message: 'Price cannot be negative' };
}
if (numPrice > 999999999) {
return { valid: false, message: 'Price is too high' };
}
return { valid: true, price: numPrice };
}
static validatePropertyType(type) {
const validTypes = ['RENT', 'SELL'];
if (!type || !validTypes.includes(type.toUpperCase())) {
return { valid: false, message: 'Invalid property type' };
}
return { valid: true, type: type.toUpperCase() };
}
static validatePropertyStatus(status) {
const validStatuses = ['DRAFT', 'ACTIVE', 'RENTED', 'FOR_SALE', 'SOLD', 'INACTIVE'];
if (!status || !validStatuses.includes(status.toUpperCase())) {
return { valid: false, message: 'Invalid property status' };
}
return { valid: true, status: status.toUpperCase() };
}
static validateTelegramId(telegramId) {
if (!telegramId) {
return { valid: false, message: 'Telegram ID is required' };
}
const numId = parseInt(telegramId);
if (isNaN(numId) || numId <= 0) {
return { valid: false, message: 'Invalid Telegram ID' };
}
return { valid: true, telegramId: numId };
}
}
module.exports = InputValidator;

View File

@ -0,0 +1,55 @@
const bcrypt = require('bcrypt');
class PasswordUtils {
static async hashPassword(password) {
try {
// Use salt rounds of 12 for strong security
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
return hashedPassword;
} catch (error) {
console.error('Error hashing password');
throw new Error('Password hashing failed');
}
}
static async verifyPassword(password, hashedPassword) {
try {
const isValid = await bcrypt.compare(password, hashedPassword);
return isValid;
} catch (error) {
console.error('Error verifying password');
return false;
}
}
static validatePasswordStrength(password) {
if (!password || typeof password !== 'string') {
return { valid: false, message: 'Password is required' };
}
if (password.length < 6) {
return { valid: false, message: 'Password must be at least 6 characters long' };
}
if (password.length > 128) {
return { valid: false, message: 'Password is too long (max 128 characters)' };
}
// Check for at least one letter and one number (optional but recommended)
const hasLetter = /[a-zA-Z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasLetter || !hasNumber) {
return {
valid: true,
message: 'Password is valid but consider adding both letters and numbers for better security',
weak: true
};
}
return { valid: true, message: 'Password is strong' };
}
}
module.exports = PasswordUtils;

114
src/utils/secureLogger.js Normal file
View File

@ -0,0 +1,114 @@
class SecureLogger {
static log(level, message, context = {}) {
const timestamp = new Date().toISOString();
const sanitizedContext = this.sanitizeLogData(context);
const logEntry = {
timestamp,
level: level.toUpperCase(),
message,
...sanitizedContext
};
switch (level.toLowerCase()) {
case 'error':
console.error(`[${timestamp}] ERROR: ${message}`, sanitizedContext);
break;
case 'warn':
console.warn(`[${timestamp}] WARN: ${message}`, sanitizedContext);
break;
case 'info':
console.info(`[${timestamp}] INFO: ${message}`, sanitizedContext);
break;
case 'debug':
if (process.env.NODE_ENV !== 'production') {
console.log(`[${timestamp}] DEBUG: ${message}`, sanitizedContext);
}
break;
default:
console.log(`[${timestamp}] ${message}`, sanitizedContext);
}
}
static sanitizeLogData(data) {
if (!data || typeof data !== 'object') {
return {};
}
const sensitiveFields = [
'password', 'token', 'authorization', 'auth', 'secret', 'key',
'phone', 'email', 'telegramId', 'userId', 'sessionId'
];
const sanitized = {};
for (const [key, value] of Object.entries(data)) {
const lowerKey = key.toLowerCase();
if (sensitiveFields.some(field => lowerKey.includes(field))) {
sanitized[key] = this.maskSensitiveData(value);
} else if (typeof value === 'object' && value !== null) {
sanitized[key] = this.sanitizeLogData(value);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
static maskSensitiveData(value) {
if (!value) return '[EMPTY]';
const str = String(value);
if (str.length <= 4) {
return '[MASKED]';
}
// Show first 2 and last 2 characters, mask the middle
return str.substring(0, 2) + '*'.repeat(str.length - 4) + str.substring(str.length - 2);
}
static info(message, context = {}) {
this.log('info', message, context);
}
static warn(message, context = {}) {
this.log('warn', message, context);
}
static error(message, context = {}) {
this.log('error', message, context);
}
static debug(message, context = {}) {
this.log('debug', message, context);
}
// Safe logging methods for common operations
static logUserAction(action, telegramId, success = true) {
this.info(`User action: ${action}`, {
telegramId: this.maskSensitiveData(telegramId),
success,
action
});
}
static logApiCall(endpoint, success = true, statusCode = null) {
this.info(`API call: ${endpoint}`, {
endpoint,
success,
statusCode
});
}
static logAuthAttempt(phone, success = true, reason = null) {
this.info(`Authentication attempt`, {
phone: this.maskSensitiveData(phone),
success,
reason
});
}
}
module.exports = SecureLogger;