Initial commit: Yaltipia Telegram Bot with API integration
This commit is contained in:
commit
c58ed95370
50
.dockerignore
Normal file
50
.dockerignore
Normal 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
3
.env.example
Normal 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
57
.gitignore
vendored
Normal 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
35
Dockerfile
Normal 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
34
Dockerfile.dev
Normal 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
31
docker-compose.dev.yml
Normal 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
44
docker-compose.yml
Normal 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
3356
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal 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
21
scripts/docker-build.sh
Normal 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
0
scripts/docker-run.sh
Normal file
465
src/api.js
Normal file
465
src/api.js
Normal 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
299
src/bot.js
Normal 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
592
src/features/auth.js
Normal 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
35
src/features/menu.js
Normal 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;
|
||||||
736
src/features/notifications.js
Normal file
736
src/features/notifications.js
Normal 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
241
src/features/search.js
Normal 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;
|
||||||
64
src/services/notificationService.js
Normal file
64
src/services/notificationService.js
Normal 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
89
src/utils/envValidator.js
Normal 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
99
src/utils/errorHandler.js
Normal 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
144
src/utils/inputValidator.js
Normal 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;
|
||||||
55
src/utils/passwordUtils.js
Normal file
55
src/utils/passwordUtils.js
Normal 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
114
src/utils/secureLogger.js
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user