init commit

This commit is contained in:
2025-09-12 21:25:54 +09:00
commit 17efb2fb53
37 changed files with 12637 additions and 0 deletions

185
src/bot.ts Normal file
View File

@@ -0,0 +1,185 @@
import 'dotenv/config';
import TelegramBot from 'node-telegram-bot-api';
import { testConnection } from './database/connection';
import { ProfileService } from './services/profileService';
import { MatchingService } from './services/matchingService';
import { NotificationService } from './services/notificationService';
import { CommandHandlers } from './handlers/commandHandlers';
import { CallbackHandlers } from './handlers/callbackHandlers';
import { MessageHandlers } from './handlers/messageHandlers';
class TelegramTinderBot {
private bot: TelegramBot;
private profileService: ProfileService;
private matchingService: MatchingService;
private notificationService: NotificationService;
private commandHandlers: CommandHandlers;
private callbackHandlers: CallbackHandlers;
private messageHandlers: MessageHandlers;
constructor() {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) {
throw new Error('TELEGRAM_BOT_TOKEN environment variable is required');
}
this.bot = new TelegramBot(token, { polling: true });
this.profileService = new ProfileService();
this.matchingService = new MatchingService();
this.notificationService = new NotificationService(this.bot);
this.commandHandlers = new CommandHandlers(this.bot);
this.messageHandlers = new MessageHandlers(this.bot);
this.callbackHandlers = new CallbackHandlers(this.bot, this.messageHandlers);
this.setupErrorHandling();
this.setupPeriodicTasks();
}
// Инициализация бота
async initialize(): Promise<void> {
try {
console.log('🚀 Initializing Telegram Tinder Bot...');
// Проверка подключения к базе данных
const dbConnected = await testConnection();
if (!dbConnected) {
throw new Error('Failed to connect to database');
}
console.log('✅ Database connected successfully');
// Установка команд бота
await this.setupBotCommands();
console.log('✅ Bot commands set up');
// Регистрация обработчиков
this.registerHandlers();
console.log('✅ Handlers registered');
console.log('🎉 Bot initialized successfully!');
} catch (error) {
console.error('❌ Failed to initialize bot:', error);
process.exit(1);
}
}
// Настройка команд бота
private async setupBotCommands(): Promise<void> {
const commands = [
{ command: 'start', description: '🚀 Начать знакомства' },
{ command: 'profile', description: '👤 Мой профиль' },
{ command: 'browse', description: '💕 Смотреть анкеты' },
{ command: 'matches', description: '💖 Мои матчи' },
{ command: 'settings', description: '⚙️ Настройки' },
{ command: 'help', description: '❓ Помощь' }
];
await this.bot.setMyCommands(commands);
}
// Регистрация обработчиков
private registerHandlers(): void {
// Команды
this.commandHandlers.register();
// Callback запросы
this.callbackHandlers.register();
// Сообщения
this.messageHandlers.register();
}
// Обработка ошибок
private setupErrorHandling(): void {
this.bot.on('polling_error', (error) => {
console.error('Polling error:', error);
});
this.bot.on('error', (error) => {
console.error('Bot error:', error);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
});
process.on('SIGINT', async () => {
console.log('🛑 Received SIGINT, shutting down gracefully...');
await this.shutdown();
});
process.on('SIGTERM', async () => {
console.log('🛑 Received SIGTERM, shutting down gracefully...');
await this.shutdown();
});
}
// Периодические задачи
private setupPeriodicTasks(): void {
// Обработка запланированных уведомлений каждые 5 минут
setInterval(async () => {
try {
await this.notificationService.processScheduledNotifications();
} catch (error) {
console.error('Error processing scheduled notifications:', error);
}
}, 5 * 60 * 1000);
// Очистка старых данных каждый день
setInterval(async () => {
try {
await this.cleanupOldData();
} catch (error) {
console.error('Error cleaning up old data:', error);
}
}, 24 * 60 * 60 * 1000);
}
// Очистка старых данных
private async cleanupOldData(): Promise<void> {
// TODO: Реализовать очистку старых уведомлений, логов и т.д.
console.log('🧹 Running cleanup tasks...');
}
// Корректное завершение работы
private async shutdown(): Promise<void> {
try {
console.log('🔄 Shutting down bot...');
await this.bot.stopPolling();
console.log('✅ Bot stopped');
process.exit(0);
} catch (error) {
console.error('❌ Error during shutdown:', error);
process.exit(1);
}
}
// Запуск бота
async start(): Promise<void> {
await this.initialize();
console.log('🤖 Bot is running and ready to match people!');
console.log(`📱 Bot username: @${(await this.bot.getMe()).username}`);
}
}
// Функция для запуска бота
async function main(): Promise<void> {
const bot = new TelegramTinderBot();
await bot.start();
}
// Запуск приложения
if (require.main === module) {
main().catch((error) => {
console.error('Failed to start bot:', error);
process.exit(1);
});
}
export { TelegramTinderBot };

View File

@@ -0,0 +1,28 @@
import { Match, MatchData } from '../models/Match';
import { v4 as uuidv4 } from 'uuid';
export class MatchController {
private matches: Match[] = [];
public createMatch(userId1: string, userId2: string): Match {
const matchData: MatchData = {
id: uuidv4(),
userId1,
userId2,
createdAt: new Date(),
isActive: true,
isSuperMatch: false,
unreadCount1: 0,
unreadCount2: 0
};
const match = new Match(matchData);
this.matches.push(match);
return match;
}
public getMatches(userId: string): Match[] {
return this.matches.filter(match =>
match.userId1 === userId || match.userId2 === userId
);
}
}

View File

@@ -0,0 +1,28 @@
import { Profile, ProfileData } from '../models/Profile';
import { ProfileService } from '../services/profileService';
export class ProfileController {
constructor(private profileService: ProfileService) {}
async createProfile(
userId: string,
age: number,
gender: 'male' | 'female' | 'other',
interests: string[]
): Promise<Profile> {
const profileData: Partial<ProfileData> = {
age,
gender,
interests
};
return await this.profileService.createProfile(userId, profileData);
}
async updateProfile(userId: string, updates: Partial<ProfileData>): Promise<Profile | null> {
return await this.profileService.updateProfile(userId, updates);
}
async getProfile(userId: string): Promise<Profile | null> {
return await this.profileService.getProfileByUserId(userId);
}
}

View File

@@ -0,0 +1,30 @@
import { MatchingService } from '../services/matchingService';
import { SwipeType } from '../models/Swipe';
export class SwipeController {
constructor(private matchingService: MatchingService) {}
async swipeLeft(userId: string, targetUserId: string) {
// Логика для обработки свайпа влево
const result = await this.matchingService.performSwipe(userId, targetUserId, 'pass');
return result;
}
async swipeRight(userId: string, targetUserId: string) {
// Логика для обработки свайпа вправо
const result = await this.matchingService.performSwipe(userId, targetUserId, 'like');
return result;
}
async superLike(userId: string, targetUserId: string) {
// Логика для супер лайка
const result = await this.matchingService.performSwipe(userId, targetUserId, 'superlike');
return result;
}
async getMatches(userId: string) {
// Логика для получения всех матчей пользователя
const matches = await this.matchingService.getUserMatches(userId);
return matches;
}
}

175
src/database/connection.ts Normal file
View File

@@ -0,0 +1,175 @@
import { Pool, PoolConfig } from 'pg';
// Конфигурация пула соединений PostgreSQL
const poolConfig: PoolConfig = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'telegram_tinder_bot',
user: process.env.DB_USERNAME || 'postgres',
...(process.env.DB_PASSWORD && { password: process.env.DB_PASSWORD }),
max: 20, // максимальное количество соединений в пуле
idleTimeoutMillis: 30000, // закрыть соединения, простаивающие 30 секунд
connectionTimeoutMillis: 2000, // время ожидания подключения
};
// Создание пула соединений
export const pool = new Pool(poolConfig);
// Обработка ошибок пула
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
process.exit(-1);
});
// Функция для выполнения запросов
export async function query(text: string, params?: any[]): Promise<any> {
const client = await pool.connect();
try {
const result = await client.query(text, params);
return result;
} catch (error) {
console.error('Database query error:', error);
throw error;
} finally {
client.release();
}
}
// Функция для выполнения транзакций
export async function transaction<T>(
callback: (client: any) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Функция для проверки подключения к базе данных
export async function testConnection(): Promise<boolean> {
try {
const result = await query('SELECT NOW()');
console.log('Database connected successfully at:', result.rows[0].now);
return true;
} catch (error) {
console.error('Database connection failed:', error);
return false;
}
}
// Функция для инициализации базы данных
export async function initializeDatabase(): Promise<void> {
try {
// Создание таблиц, если они не существуют
await query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
telegram_id BIGINT UNIQUE NOT NULL,
username VARCHAR(255),
first_name VARCHAR(255),
last_name VARCHAR(255),
language_code VARCHAR(10) DEFAULT 'en',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
last_active_at TIMESTAMP DEFAULT NOW()
);
`);
await query(`
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
age INTEGER NOT NULL CHECK (age >= 18 AND age <= 100),
gender VARCHAR(10) NOT NULL CHECK (gender IN ('male', 'female', 'other')),
interested_in VARCHAR(10) NOT NULL CHECK (interested_in IN ('male', 'female', 'both')),
bio TEXT,
photos JSONB DEFAULT '[]',
interests JSONB DEFAULT '[]',
city VARCHAR(255),
education VARCHAR(255),
job VARCHAR(255),
height INTEGER,
location_lat DECIMAL(10, 8),
location_lon DECIMAL(11, 8),
search_min_age INTEGER DEFAULT 18,
search_max_age INTEGER DEFAULT 50,
search_max_distance INTEGER DEFAULT 50,
is_verified BOOLEAN DEFAULT false,
is_visible BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await query(`
CREATE TABLE IF NOT EXISTS swipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
target_user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL CHECK (type IN ('like', 'pass', 'superlike')),
created_at TIMESTAMP DEFAULT NOW(),
is_match BOOLEAN DEFAULT false,
UNIQUE(user_id, target_user_id)
);
`);
await query(`
CREATE TABLE IF NOT EXISTS matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id_1 UUID REFERENCES users(id) ON DELETE CASCADE,
user_id_2 UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
last_message_at TIMESTAMP,
is_active BOOLEAN DEFAULT true,
is_super_match BOOLEAN DEFAULT false,
unread_count_1 INTEGER DEFAULT 0,
unread_count_2 INTEGER DEFAULT 0,
UNIQUE(user_id_1, user_id_2)
);
`);
await query(`
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
match_id UUID REFERENCES matches(id) ON DELETE CASCADE,
sender_id UUID REFERENCES users(id) ON DELETE CASCADE,
receiver_id UUID REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
message_type VARCHAR(20) DEFAULT 'text' CHECK (message_type IN ('text', 'photo', 'gif', 'sticker')),
created_at TIMESTAMP DEFAULT NOW(),
is_read BOOLEAN DEFAULT false
);
`);
// Создание индексов для оптимизации
await query(`
CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id);
CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
CREATE INDEX IF NOT EXISTS idx_profiles_location ON profiles(latitude, longitude);
CREATE INDEX IF NOT EXISTS idx_profiles_age_gender ON profiles(age, gender, looking_for);
CREATE INDEX IF NOT EXISTS idx_swipes_swiper_swiped ON swipes(swiper_id, swiped_id);
CREATE INDEX IF NOT EXISTS idx_matches_users ON matches(user1_id, user2_id);
CREATE INDEX IF NOT EXISTS idx_messages_match ON messages(match_id, created_at);
`);
console.log('Database initialized successfully');
} catch (error) {
console.error('Database initialization failed:', error);
throw error;
}
}
// Функция для очистки пула соединений
export async function closePool(): Promise<void> {
await pool.end();
console.log('Database pool closed');
}

View File

@@ -0,0 +1,198 @@
-- Database initialization script for Telegram Tinder Bot
-- Create UUID extension if not exists
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
telegram_id BIGINT UNIQUE NOT NULL,
username VARCHAR(255),
first_name VARCHAR(255),
last_name VARCHAR(255),
language_code VARCHAR(10) DEFAULT 'en',
is_premium BOOLEAN DEFAULT FALSE,
is_blocked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Profiles table
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
age INTEGER NOT NULL CHECK (age >= 18 AND age <= 100),
gender VARCHAR(20) NOT NULL CHECK (gender IN ('male', 'female', 'other')),
looking_for VARCHAR(20) NOT NULL CHECK (looking_for IN ('male', 'female', 'both')),
bio TEXT,
location VARCHAR(255),
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
photos TEXT[], -- Array of photo URLs/file IDs
interests TEXT[], -- Array of interests
education VARCHAR(255),
occupation VARCHAR(255),
height INTEGER, -- in cm
smoking VARCHAR(20) CHECK (smoking IN ('never', 'sometimes', 'regularly')),
drinking VARCHAR(20) CHECK (drinking IN ('never', 'sometimes', 'regularly')),
relationship_type VARCHAR(30) CHECK (relationship_type IN ('casual', 'serious', 'friendship', 'anything')),
verification_status VARCHAR(20) DEFAULT 'unverified' CHECK (verification_status IN ('unverified', 'pending', 'verified')),
is_active BOOLEAN DEFAULT TRUE,
is_visible BOOLEAN DEFAULT TRUE,
last_active TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
-- Swipes table
CREATE TABLE IF NOT EXISTS swipes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
swiper_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
swiped_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
direction VARCHAR(10) NOT NULL CHECK (direction IN ('left', 'right', 'super')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(swiper_id, swiped_id)
);
-- Matches table
CREATE TABLE IF NOT EXISTS matches (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user1_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user2_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'blocked', 'unmatched')),
matched_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_message_at TIMESTAMP WITH TIME ZONE,
user1_unmatched BOOLEAN DEFAULT FALSE,
user2_unmatched BOOLEAN DEFAULT FALSE,
CHECK (user1_id != user2_id),
UNIQUE(user1_id, user2_id)
);
-- Messages table
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
message_type VARCHAR(20) DEFAULT 'text' CHECK (message_type IN ('text', 'photo', 'video', 'voice', 'sticker', 'gif')),
file_id VARCHAR(255), -- For media messages
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Reports table
CREATE TABLE IF NOT EXISTS reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reported_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reason VARCHAR(50) NOT NULL CHECK (reason IN ('inappropriate', 'fake', 'harassment', 'spam', 'other')),
description TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'resolved')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Blocks table
CREATE TABLE IF NOT EXISTS blocks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(blocker_id, blocked_id)
);
-- User sessions table (for bot state management)
CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_data JSONB,
current_step VARCHAR(50),
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
-- Indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id);
CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
CREATE INDEX IF NOT EXISTS idx_profiles_location ON profiles(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_profiles_age_gender ON profiles(age, gender, looking_for);
CREATE INDEX IF NOT EXISTS idx_profiles_active ON profiles(is_active, is_visible);
CREATE INDEX IF NOT EXISTS idx_swipes_swiper ON swipes(swiper_id);
CREATE INDEX IF NOT EXISTS idx_swipes_swiped ON swipes(swiped_id);
CREATE INDEX IF NOT EXISTS idx_matches_users ON matches(user1_id, user2_id);
CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status);
CREATE INDEX IF NOT EXISTS idx_messages_match ON messages(match_id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON blocks(blocker_id);
CREATE INDEX IF NOT EXISTS idx_blocks_blocked ON blocks(blocked_id);
-- Functions
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER user_sessions_updated_at BEFORE UPDATE ON user_sessions
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to create a match when both users swiped right
CREATE OR REPLACE FUNCTION check_for_match()
RETURNS TRIGGER AS $$
BEGIN
-- Only proceed if this is a right swipe or super like
IF NEW.direction IN ('right', 'super') THEN
-- Check if the other user also swiped right on this user
IF EXISTS (
SELECT 1 FROM swipes
WHERE swiper_id = NEW.swiped_id
AND swiped_id = NEW.swiper_id
AND direction IN ('right', 'super')
) THEN
-- Create a match if it doesn't exist
INSERT INTO matches (user1_id, user2_id)
VALUES (
LEAST(NEW.swiper_id, NEW.swiped_id),
GREATEST(NEW.swiper_id, NEW.swiped_id)
)
ON CONFLICT (user1_id, user2_id) DO NOTHING;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to automatically create matches
CREATE TRIGGER auto_match_trigger
AFTER INSERT ON swipes
FOR EACH ROW EXECUTE FUNCTION check_for_match();
-- Function to update last_message_at in matches
CREATE OR REPLACE FUNCTION update_match_last_message()
RETURNS TRIGGER AS $$
BEGIN
UPDATE matches
SET last_message_at = NEW.created_at
WHERE id = NEW.match_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to update match last message time
CREATE TRIGGER update_match_last_message_trigger
AFTER INSERT ON messages
FOR EACH ROW EXECUTE FUNCTION update_match_last_message();

View File

@@ -0,0 +1,802 @@
import TelegramBot, { CallbackQuery, InlineKeyboardMarkup } from 'node-telegram-bot-api';
import { ProfileService } from '../services/profileService';
import { MatchingService } from '../services/matchingService';
import { ChatService } from '../services/chatService';
import { Profile } from '../models/Profile';
import { MessageHandlers } from './messageHandlers';
export class CallbackHandlers {
private bot: TelegramBot;
private profileService: ProfileService;
private matchingService: MatchingService;
private chatService: ChatService;
private messageHandlers: MessageHandlers;
constructor(bot: TelegramBot, messageHandlers: MessageHandlers) {
this.bot = bot;
this.profileService = new ProfileService();
this.matchingService = new MatchingService();
this.chatService = new ChatService();
this.messageHandlers = messageHandlers;
}
register(): void {
this.bot.on('callback_query', (query) => this.handleCallback(query));
}
async handleCallback(query: CallbackQuery): Promise<void> {
if (!query.data || !query.from || !query.message) return;
const telegramId = query.from.id.toString();
const chatId = query.message.chat.id;
const data = query.data;
try {
// Основные действия профиля
if (data === 'create_profile') {
await this.handleCreateProfile(chatId, telegramId);
} else if (data.startsWith('gender_')) {
const gender = data.replace('gender_', '');
await this.handleGenderSelection(chatId, telegramId, gender);
} else if (data === 'view_my_profile') {
await this.handleViewMyProfile(chatId, telegramId);
} else if (data === 'edit_profile') {
await this.handleEditProfile(chatId, telegramId);
} else if (data === 'manage_photos') {
await this.handleManagePhotos(chatId, telegramId);
}
// Просмотр анкет и свайпы
else if (data === 'start_browsing') {
await this.handleStartBrowsing(chatId, telegramId);
} else if (data === 'next_candidate') {
await this.handleNextCandidate(chatId, telegramId);
} else if (data.startsWith('like_')) {
const targetUserId = data.replace('like_', '');
await this.handleLike(chatId, telegramId, targetUserId);
} else if (data.startsWith('dislike_')) {
const targetUserId = data.replace('dislike_', '');
await this.handleDislike(chatId, telegramId, targetUserId);
} else if (data.startsWith('superlike_')) {
const targetUserId = data.replace('superlike_', '');
await this.handleSuperlike(chatId, telegramId, targetUserId);
} else if (data.startsWith('view_profile_')) {
const targetUserId = data.replace('view_profile_', '');
await this.handleViewProfile(chatId, telegramId, targetUserId);
} else if (data.startsWith('more_photos_')) {
const targetUserId = data.replace('more_photos_', '');
await this.handleMorePhotos(chatId, telegramId, targetUserId);
}
// Матчи и чаты
else if (data === 'view_matches') {
await this.handleViewMatches(chatId, telegramId);
} else if (data === 'open_chats') {
await this.handleOpenChats(chatId, telegramId);
} else if (data.startsWith('chat_')) {
const matchId = data.replace('chat_', '');
await this.handleOpenChat(chatId, telegramId, matchId);
} else if (data.startsWith('send_message_')) {
const matchId = data.replace('send_message_', '');
await this.handleSendMessage(chatId, telegramId, matchId);
} else if (data.startsWith('view_chat_profile_')) {
const matchId = data.replace('view_chat_profile_', '');
await this.handleViewChatProfile(chatId, telegramId, matchId);
} else if (data.startsWith('unmatch_')) {
const matchId = data.replace('unmatch_', '');
await this.handleUnmatch(chatId, telegramId, matchId);
} else if (data.startsWith('confirm_unmatch_')) {
const matchId = data.replace('confirm_unmatch_', '');
await this.handleConfirmUnmatch(chatId, telegramId, matchId);
}
// Настройки
else if (data === 'settings') {
await this.handleSettings(chatId, telegramId);
} else if (data === 'search_settings') {
await this.handleSearchSettings(chatId, telegramId);
} else if (data === 'notification_settings') {
await this.handleNotificationSettings(chatId, telegramId);
}
// Информация
else if (data === 'how_it_works') {
await this.handleHowItWorks(chatId);
} else if (data === 'back_to_browsing') {
await this.handleStartBrowsing(chatId, telegramId);
}
else {
await this.bot.answerCallbackQuery(query.id, {
text: 'Функция в разработке!',
show_alert: false
});
return;
}
await this.bot.answerCallbackQuery(query.id);
} catch (error) {
console.error('Callback handler error:', error);
await this.bot.answerCallbackQuery(query.id, {
text: 'Произошла ошибка. Попробуйте еще раз.',
show_alert: true
});
}
}
// Создание профиля
async handleCreateProfile(chatId: number, telegramId: string): Promise<void> {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '👨 Мужской', callback_data: 'gender_male' }],
[{ text: '👩 Женский', callback_data: 'gender_female' }],
[{ text: '🔀 Другой', callback_data: 'gender_other' }]
]
};
await this.bot.sendMessage(
chatId,
'👋 Давайте создадим ваш профиль!\n\n' +
'🚹🚺 Сначала выберите ваш пол:',
{ reply_markup: keyboard }
);
}
// Выбор пола
async handleGenderSelection(chatId: number, telegramId: string, gender: string): Promise<void> {
this.messageHandlers.startProfileCreation(telegramId, gender);
await this.bot.sendMessage(
chatId,
'👍 Отлично!\n\n📝 Теперь напишите ваше имя:'
);
}
// Просмотр собственного профиля
async handleViewMyProfile(chatId: number, telegramId: string): Promise<void> {
const profile = await this.profileService.getProfileByTelegramId(telegramId);
if (!profile) {
await this.bot.sendMessage(chatId, '❌ Профиль не найден');
return;
}
await this.showProfile(chatId, profile, true);
}
// Редактирование профиля
async handleEditProfile(chatId: number, telegramId: string): Promise<void> {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '📝 Имя', callback_data: 'edit_name' },
{ text: '📅 Возраст', callback_data: 'edit_age' }
],
[
{ text: '📍 Город', callback_data: 'edit_city' },
{ text: '💼 Работа', callback_data: 'edit_job' }
],
[
{ text: '📖 О себе', callback_data: 'edit_bio' },
{ text: '🎯 Интересы', callback_data: 'edit_interests' }
],
[{ text: '👈 Назад к профилю', callback_data: 'view_my_profile' }]
]
};
await this.bot.sendMessage(
chatId,
'✏️ Что хотите изменить в профиле?',
{ reply_markup: keyboard }
);
}
// Управление фотографиями
async handleManagePhotos(chatId: number, telegramId: string): Promise<void> {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '📷 Добавить фото', callback_data: 'add_photo' },
{ text: '🗑 Удалить фото', callback_data: 'delete_photo' }
],
[
{ text: '⭐ Сделать главным', callback_data: 'set_main_photo' },
{ text: '🔄 Изменить порядок', callback_data: 'reorder_photos' }
],
[{ text: '👈 Назад к профилю', callback_data: 'view_my_profile' }]
]
};
await this.bot.sendMessage(
chatId,
'📸 Управление фотографиями\n\nВыберите действие:',
{ reply_markup: keyboard }
);
}
// Начать просмотр анкет
async handleStartBrowsing(chatId: number, telegramId: string): Promise<void> {
const profile = await this.profileService.getProfileByTelegramId(telegramId);
if (!profile) {
await this.bot.sendMessage(chatId, '❌ Сначала создайте профиль!');
return;
}
await this.showNextCandidate(chatId, telegramId);
}
// Следующий кандидат
async handleNextCandidate(chatId: number, telegramId: string): Promise<void> {
await this.showNextCandidate(chatId, telegramId);
}
// Лайк
async handleLike(chatId: number, telegramId: string, targetUserId: string): Promise<void> {
try {
const result = await this.matchingService.performSwipe(telegramId, targetUserId, 'like');
if (result.isMatch) {
// Это матч!
const targetProfile = await this.profileService.getProfileByUserId(targetUserId);
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '💬 Написать сообщение', callback_data: 'chat_' + targetUserId },
{ text: '👤 Посмотреть профиль', callback_data: 'view_profile_' + targetUserId }
],
[{ text: '🔍 Продолжить поиск', callback_data: 'next_candidate' }]
]
};
await this.bot.sendMessage(
chatId,
'🎉 ЭТО МАТЧ! 💕\n\n' +
'Вы понравились друг другу с ' + (targetProfile?.name || 'этим пользователем') + '!\n\n' +
'Теперь вы можете начать общение!',
{ reply_markup: keyboard }
);
} else {
await this.bot.sendMessage(chatId, '👍 Лайк отправлен!');
await this.showNextCandidate(chatId, telegramId);
}
} catch (error) {
await this.bot.sendMessage(chatId, '❌ Ошибка при отправке лайка');
console.error('Like error:', error);
}
}
// Дизлайк
async handleDislike(chatId: number, telegramId: string, targetUserId: string): Promise<void> {
try {
await this.matchingService.performSwipe(telegramId, targetUserId, 'pass');
await this.showNextCandidate(chatId, telegramId);
} catch (error) {
await this.bot.sendMessage(chatId, '❌ Ошибка при отправке дизлайка');
console.error('Dislike error:', error);
}
}
// Супер лайк
async handleSuperlike(chatId: number, telegramId: string, targetUserId: string): Promise<void> {
try {
const result = await this.matchingService.performSwipe(telegramId, targetUserId, 'superlike');
if (result.isMatch) {
const targetProfile = await this.profileService.getProfileByUserId(targetUserId);
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '💬 Написать сообщение', callback_data: 'chat_' + targetUserId },
{ text: '👤 Посмотреть профиль', callback_data: 'view_profile_' + targetUserId }
],
[{ text: '🔍 Продолжить поиск', callback_data: 'next_candidate' }]
]
};
await this.bot.sendMessage(
chatId,
'💖 СУПЕР МАТЧ! ⭐\n\n' +
'Ваш супер лайк произвел впечатление на ' + (targetProfile?.name || 'этого пользователя') + '!\n\n' +
'Начните общение первыми!',
{ reply_markup: keyboard }
);
} else {
await this.bot.sendMessage(chatId, '💖 Супер лайк отправлен!');
await this.showNextCandidate(chatId, telegramId);
}
} catch (error) {
await this.bot.sendMessage(chatId, '❌ Ошибка при отправке супер лайка');
console.error('Superlike error:', error);
}
}
// Просмотр профиля кандидата
async handleViewProfile(chatId: number, telegramId: string, targetUserId: string): Promise<void> {
const targetProfile = await this.profileService.getProfileByUserId(targetUserId);
if (!targetProfile) {
await this.bot.sendMessage(chatId, '❌ Профиль не найден');
return;
}
await this.showProfile(chatId, targetProfile, false, telegramId);
}
// Показать больше фотографий
async handleMorePhotos(chatId: number, telegramId: string, targetUserId: string): Promise<void> {
const targetProfile = await this.profileService.getProfileByUserId(targetUserId);
if (!targetProfile || targetProfile.photos.length <= 1) {
await this.bot.sendMessage(chatId, '📷 У пользователя нет дополнительных фотографий');
return;
}
for (let i = 1; i < targetProfile.photos.length; i++) {
const photoFileId = targetProfile.photos[i];
await this.bot.sendPhoto(chatId, photoFileId);
}
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '👎 Не нравится', callback_data: 'dislike_' + targetUserId },
{ text: '💖 Супер лайк', callback_data: 'superlike_' + targetUserId },
{ text: '👍 Нравится', callback_data: 'like_' + targetUserId }
]
]
};
await this.bot.sendMessage(
chatId,
'📸 Все фотографии просмотрены!\n\nВаше решение?',
{ reply_markup: keyboard }
);
}
// Просмотр матчей
async handleViewMatches(chatId: number, telegramId: string): Promise<void> {
const matches = await this.matchingService.getUserMatches(telegramId);
if (matches.length === 0) {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '🔍 Начать поиск', callback_data: 'start_browsing' }]
]
};
await this.bot.sendMessage(
chatId,
'💔 У вас пока нет матчей\n\n' +
'Попробуйте просмотреть больше анкет!',
{ reply_markup: keyboard }
);
return;
}
let matchText = 'Ваши матчи (' + matches.length + '):\n\n';
for (const match of matches) {
const otherUserId = match.userId1 === telegramId ? match.userId2 : match.userId1;
const otherProfile = await this.profileService.getProfileByUserId(otherUserId);
if (otherProfile) {
matchText += '💖 ' + otherProfile.name + ', ' + otherProfile.age + '\n';
matchText += '📍 ' + (otherProfile.city || 'Не указан') + '\n\n';
}
}
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '💬 Открыть чаты', callback_data: 'open_chats' }],
[{ text: '🔍 Найти еще', callback_data: 'start_browsing' }]
]
};
await this.bot.sendMessage(chatId, matchText, { reply_markup: keyboard });
}
// Открыть чаты
// Открыть список чатов
async handleOpenChats(chatId: number, telegramId: string): Promise<void> {
const chats = await this.chatService.getUserChats(telegramId);
if (chats.length === 0) {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '🔍 Найти матчи', callback_data: 'start_browsing' }],
[{ text: '💕 Мои матчи', callback_data: 'view_matches' }]
]
};
await this.bot.sendMessage(
chatId,
'💬 У вас пока нет активных чатов\n\n' +
'Начните просматривать анкеты и получите первые матчи!',
{ reply_markup: keyboard }
);
return;
}
let messageText = '💬 Ваши чаты:\n\n';
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: []
};
for (const chat of chats.slice(0, 10)) { // Показываем только первые 10 чатов
const unreadBadge = chat.unreadCount > 0 ? ` (${chat.unreadCount})` : '';
const lastMessagePreview = chat.lastMessage
? (chat.lastMessage.length > 30
? chat.lastMessage.substring(0, 30) + '...'
: chat.lastMessage)
: 'Новый матч';
messageText += `💕 ${chat.otherUserName}${unreadBadge}\n`;
messageText += `💬 ${lastMessagePreview}\n\n`;
keyboard.inline_keyboard.push([
{ text: `💬 ${chat.otherUserName}${unreadBadge}`, callback_data: `chat_${chat.matchId}` }
]);
}
if (chats.length > 10) {
messageText += `...и еще ${chats.length - 10} чатов`;
}
keyboard.inline_keyboard.push([
{ text: '🔍 Найти еще', callback_data: 'start_browsing' },
{ text: '💕 Матчи', callback_data: 'view_matches' }
]);
await this.bot.sendMessage(chatId, messageText, { reply_markup: keyboard });
}
// Открыть конкретный чат
async handleOpenChat(chatId: number, telegramId: string, matchId: string): Promise<void> {
const matchInfo = await this.chatService.getMatchInfo(matchId, telegramId);
if (!matchInfo) {
await this.bot.sendMessage(chatId, '❌ Чат не найден или недоступен');
return;
}
// Отмечаем сообщения как прочитанные
await this.chatService.markMessagesAsRead(matchId, telegramId);
// Получаем последние сообщения
const messages = await this.chatService.getChatMessages(matchId, 10);
let chatText = `💬 Чат с ${matchInfo.otherUserProfile?.name}\n\n`;
if (messages.length === 0) {
chatText += '📝 Начните общение! Напишите первое сообщение.\n\n';
} else {
chatText += '📝 Последние сообщения:\n\n';
for (const message of messages.slice(-5)) { // Показываем последние 5 сообщений
const currentUserId = await this.profileService.getUserIdByTelegramId(telegramId);
const isFromMe = message.senderId === currentUserId;
const sender = isFromMe ? 'Вы' : matchInfo.otherUserProfile?.name;
const time = message.createdAt.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
});
chatText += `${sender} (${time}):\n${message.content}\n\n`;
}
}
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '✍️ Написать сообщение', callback_data: `send_message_${matchId}` }
],
[
{ text: '👤 Профиль', callback_data: `view_chat_profile_${matchId}` },
{ text: '💔 Удалить матч', callback_data: `unmatch_${matchId}` }
],
[
{ text: '← Назад к чатам', callback_data: 'open_chats' }
]
]
};
await this.bot.sendMessage(chatId, chatText, { reply_markup: keyboard });
}
// Отправить сообщение
async handleSendMessage(chatId: number, telegramId: string, matchId: string): Promise<void> {
const matchInfo = await this.chatService.getMatchInfo(matchId, telegramId);
if (!matchInfo) {
await this.bot.sendMessage(chatId, '❌ Чат не найден или недоступен');
return;
}
// Устанавливаем состояние ожидания сообщения
this.messageHandlers.setWaitingForMessage(telegramId, matchId);
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '❌ Отмена', callback_data: `chat_${matchId}` }]
]
};
await this.bot.sendMessage(
chatId,
`✍️ Напишите сообщение для ${matchInfo.otherUserProfile?.name}:\n\n` +
'💡 Просто отправьте текст в этот чат',
{ reply_markup: keyboard }
);
}
// Просмотр профиля в чате
async handleViewChatProfile(chatId: number, telegramId: string, matchId: string): Promise<void> {
const matchInfo = await this.chatService.getMatchInfo(matchId, telegramId);
if (!matchInfo || !matchInfo.otherUserProfile) {
await this.bot.sendMessage(chatId, '❌ Профиль не найден');
return;
}
await this.showProfile(chatId, matchInfo.otherUserProfile, false, telegramId);
}
// Удалить матч (размэтчиться)
async handleUnmatch(chatId: number, telegramId: string, matchId: string): Promise<void> {
const matchInfo = await this.chatService.getMatchInfo(matchId, telegramId);
if (!matchInfo) {
await this.bot.sendMessage(chatId, '❌ Матч не найден');
return;
}
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '✅ Да, удалить', callback_data: `confirm_unmatch_${matchId}` },
{ text: '❌ Отмена', callback_data: `chat_${matchId}` }
]
]
};
await this.bot.sendMessage(
chatId,
`💔 Вы уверены, что хотите удалить матч с ${matchInfo.otherUserProfile?.name}?\n\n` +
'⚠️ Это действие нельзя отменить. Вся переписка будет удалена.',
{ reply_markup: keyboard }
);
}
// Подтвердить удаление матча
async handleConfirmUnmatch(chatId: number, telegramId: string, matchId: string): Promise<void> {
const success = await this.chatService.unmatch(matchId, telegramId);
if (success) {
await this.bot.sendMessage(
chatId,
'💔 Матч удален\n\n' +
'Вы больше не увидите этого пользователя в своих матчах.'
);
// Возвращаемся к списку чатов
setTimeout(() => {
this.handleOpenChats(chatId, telegramId);
}, 2000);
} else {
await this.bot.sendMessage(chatId, '❌ Не удалось удалить матч. Попробуйте еще раз.');
}
}
// Настройки
async handleSettings(chatId: number, telegramId: string): Promise<void> {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '🔍 Настройки поиска', callback_data: 'search_settings' },
{ text: '🔔 Уведомления', callback_data: 'notification_settings' }
],
[
{ text: '🚫 Скрыть профиль', callback_data: 'hide_profile' },
{ text: '🗑 Удалить профиль', callback_data: 'delete_profile' }
]
]
};
await this.bot.sendMessage(
chatId,
'⚙️ Настройки профиля\n\nВыберите что хотите изменить:',
{ reply_markup: keyboard }
);
}
// Настройки поиска
async handleSearchSettings(chatId: number, telegramId: string): Promise<void> {
await this.bot.sendMessage(
chatId,
'🔍 Настройки поиска будут доступны в следующем обновлении!'
);
}
// Настройки уведомлений
async handleNotificationSettings(chatId: number, telegramId: string): Promise<void> {
await this.bot.sendMessage(
chatId,
'🔔 Настройки уведомлений будут доступны в следующем обновлении!'
);
}
// Как это работает
async handleHowItWorks(chatId: number): Promise<void> {
const helpText =
'🎯 Как работает Telegram Tinder Bot?\n\n' +
'1⃣ Создайте профиль\n' +
' • Добавьте фото и описание\n' +
' • Укажите ваши предпочтения\n\n' +
'2⃣ Просматривайте анкеты\n' +
' • Ставьте лайки понравившимся\n' +
' • Используйте супер лайки для особых случаев\n\n' +
'3⃣ Получайте матчи\n' +
' • Когда ваш лайк взаимен - это матч!\n' +
' • Начинайте общение\n\n' +
'4⃣ Общайтесь и знакомьтесь\n' +
' • Находите общие интересы\n' +
' • Договаривайтесь о встрече\n\n' +
'<27><> Советы:\n' +
'• Используйте качественные фото\n' +
'• Напишите интересное описание\n' +
'• Будьте вежливы в общении\n\n' +
'❤️ Удачи в поиске любви!';
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '🚀 Создать профиль', callback_data: 'create_profile' }]
]
};
await this.bot.sendMessage(chatId, helpText, { reply_markup: keyboard });
}
// Вспомогательные методы
async showProfile(chatId: number, profile: Profile, isOwner: boolean = false, viewerId?: string): Promise<void> {
const mainPhotoFileId = profile.photos[0]; // Первое фото - главное
let profileText = '👤 ' + profile.name + ', ' + profile.age + '\n';
profileText += '📍 ' + (profile.city || 'Не указан') + '\n';
if (profile.job) profileText += '💼 ' + profile.job + '\n';
if (profile.education) profileText += '🎓 ' + profile.education + '\n';
if (profile.height) profileText += '📏 ' + profile.height + ' см\n';
profileText += '\n📝 ' + (profile.bio || 'Описание не указано') + '\n';
if (profile.interests.length > 0) {
profileText += '\n🎯 Интересы: ' + profile.interests.join(', ');
}
let keyboard: InlineKeyboardMarkup;
if (isOwner) {
keyboard = {
inline_keyboard: [
[
{ text: '✏️ Редактировать', callback_data: 'edit_profile' },
{ text: '📸 Фото', callback_data: 'manage_photos' }
],
[{ text: '🔍 Начать поиск', callback_data: 'start_browsing' }]
]
};
} else {
keyboard = {
inline_keyboard: [
[
{ text: '👎 Не нравится', callback_data: 'dislike_' + profile.userId },
{ text: '💖 Супер лайк', callback_data: 'superlike_' + profile.userId },
{ text: '👍 Нравится', callback_data: 'like_' + profile.userId }
],
[{ text: '🔍 Продолжить поиск', callback_data: 'next_candidate' }]
]
};
}
// Проверяем, есть ли валидное фото (file_id или URL)
const hasValidPhoto = mainPhotoFileId &&
(mainPhotoFileId.startsWith('http') ||
mainPhotoFileId.startsWith('AgAC') ||
mainPhotoFileId.length > 20); // file_id обычно длинные
if (hasValidPhoto) {
try {
await this.bot.sendPhoto(chatId, mainPhotoFileId, {
caption: profileText,
reply_markup: keyboard
});
} catch (error) {
// Если не удалось отправить фото, отправляем текст
await this.bot.sendMessage(chatId, '🖼 Фото недоступно\n\n' + profileText, {
reply_markup: keyboard
});
}
} else {
// Отправляем как текстовое сообщение
await this.bot.sendMessage(chatId, profileText, {
reply_markup: keyboard
});
}
}
async showNextCandidate(chatId: number, telegramId: string): Promise<void> {
const candidate = await this.matchingService.getNextCandidate(telegramId);
if (!candidate) {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '🔄 Попробовать еще раз', callback_data: 'start_browsing' }],
[{ text: '💕 Мои матчи', callback_data: 'view_matches' }]
]
};
await this.bot.sendMessage(
chatId,
'🎉 Вы просмотрели всех доступных кандидатов!\n\n' +
'⏰ Попробуйте позже - возможно появятся новые анкеты!',
{ reply_markup: keyboard }
);
return;
}
const candidatePhotoFileId = candidate.photos[0]; // Первое фото - главное
let candidateText = candidate.name + ', ' + candidate.age + '\n';
candidateText += '📍 ' + (candidate.city || 'Не указан') + '\n';
if (candidate.job) candidateText += '💼 ' + candidate.job + '\n';
if (candidate.education) candidateText += '🎓 ' + candidate.education + '\n';
if (candidate.height) candidateText += '<27><> ' + candidate.height + ' см\n';
candidateText += '\n📝 ' + (candidate.bio || 'Описание отсутствует') + '\n';
if (candidate.interests.length > 0) {
candidateText += '\n🎯 Интересы: ' + candidate.interests.join(', ');
}
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '👎 Не нравится', callback_data: 'dislike_' + candidate.userId },
{ text: '💖 Супер лайк', callback_data: 'superlike_' + candidate.userId },
{ text: '👍 Нравится', callback_data: 'like_' + candidate.userId }
],
[
{ text: '👤 Профиль', callback_data: 'view_profile_' + candidate.userId },
{ text: '📸 Еще фото', callback_data: 'more_photos_' + candidate.userId }
],
[{ text: '⏭ Следующий', callback_data: 'next_candidate' }]
]
};
// Проверяем, есть ли валидное фото (file_id или URL)
const hasValidPhoto = candidatePhotoFileId &&
(candidatePhotoFileId.startsWith('http') ||
candidatePhotoFileId.startsWith('AgAC') ||
candidatePhotoFileId.length > 20); // file_id обычно длинные
if (hasValidPhoto) {
try {
await this.bot.sendPhoto(chatId, candidatePhotoFileId, {
caption: candidateText,
reply_markup: keyboard
});
} catch (error) {
// Если не удалось отправить фото, отправляем текст
await this.bot.sendMessage(chatId, '🖼 Фото недоступно\n\n' + candidateText, {
reply_markup: keyboard
});
}
} else {
// Отправляем как текстовое сообщение
await this.bot.sendMessage(chatId, '📝 ' + candidateText, {
reply_markup: keyboard
});
}
}
}

View File

@@ -0,0 +1,302 @@
import TelegramBot, { Message, InlineKeyboardMarkup } from 'node-telegram-bot-api';
import { ProfileService } from '../services/profileService';
import { MatchingService } from '../services/matchingService';
import { Profile } from '../models/Profile';
export class CommandHandlers {
private bot: TelegramBot;
private profileService: ProfileService;
private matchingService: MatchingService;
constructor(bot: TelegramBot) {
this.bot = bot;
this.profileService = new ProfileService();
this.matchingService = new MatchingService();
}
register(): void {
this.bot.onText(/\/start/, (msg: Message) => this.handleStart(msg));
this.bot.onText(/\/help/, (msg: Message) => this.handleHelp(msg));
this.bot.onText(/\/profile/, (msg: Message) => this.handleProfile(msg));
this.bot.onText(/\/browse/, (msg: Message) => this.handleBrowse(msg));
this.bot.onText(/\/matches/, (msg: Message) => this.handleMatches(msg));
this.bot.onText(/\/settings/, (msg: Message) => this.handleSettings(msg));
this.bot.onText(/\/create_profile/, (msg: Message) => this.handleCreateProfile(msg));
}
async handleStart(msg: Message): Promise<void> {
const userId = msg.from?.id.toString();
if (!userId) return;
// Проверяем есть ли у пользователя профиль
const existingProfile = await this.profileService.getProfileByTelegramId(userId);
if (existingProfile) {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '👤 Мой профиль', callback_data: 'view_my_profile' },
{ text: '🔍 Просмотр анкет', callback_data: 'start_browsing' }
],
[
{ text: '💕 Мои матчи', callback_data: 'view_matches' },
{ text: '⚙️ Настройки', callback_data: 'settings' }
]
]
};
await this.bot.sendMessage(
msg.chat.id,
`🎉 С возвращением, ${existingProfile.name}!\n\n` +
`💖 Telegram Tinder Bot готов к работе!\n\n` +
`Что хотите сделать?`,
{ reply_markup: keyboard }
);
} else {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '<27> Создать профиль', callback_data: 'create_profile' }],
[{ text: ' Как это работает?', callback_data: 'how_it_works' }]
]
};
await this.bot.sendMessage(
msg.chat.id,
`🎉 Добро пожаловать в Telegram Tinder Bot!\n\n` +
`💕 Здесь вы можете найти свою вторую половинку!\n\n` +
`Для начала создайте свой профиль:`,
{ reply_markup: keyboard }
);
}
}
async handleHelp(msg: Message): Promise<void> {
const helpText = `
🤖 Telegram Tinder Bot - Справка
📋 Доступные команды:
/start - Главное меню
/profile - Управление профилем
/browse - Просмотр анкет
/matches - Ваши матчи
/settings - Настройки
/help - Эта справка
<EFBFBD> Как использовать:
1. Создайте профиль с фото и описанием
2. Просматривайте анкеты других пользователей
3. Ставьте лайки понравившимся
4. Общайтесь с взаимными матчами!
❤️ Удачи в поиске любви!
`;
await this.bot.sendMessage(msg.chat.id, helpText.trim());
}
async handleProfile(msg: Message): Promise<void> {
const userId = msg.from?.id.toString();
if (!userId) return;
const profile = await this.profileService.getProfileByTelegramId(userId);
if (!profile) {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '🚀 Создать профиль', callback_data: 'create_profile' }]
]
};
await this.bot.sendMessage(
msg.chat.id,
'❌ У вас пока нет профиля.\nСоздайте его для начала использования бота!',
{ reply_markup: keyboard }
);
return;
}
// Показываем профиль пользователя
await this.showUserProfile(msg.chat.id, profile, true);
}
async handleBrowse(msg: Message): Promise<void> {
const userId = msg.from?.id.toString();
if (!userId) return;
const profile = await this.profileService.getProfileByTelegramId(userId);
if (!profile) {
await this.bot.sendMessage(
msg.chat.id,
'❌ Сначала создайте профиль!\nИспользуйте команду /start'
);
return;
}
await this.showNextCandidate(msg.chat.id, userId);
}
async handleMatches(msg: Message): Promise<void> {
const userId = msg.from?.id.toString();
if (!userId) return;
// Получаем матчи пользователя
const matches = await this.matchingService.getUserMatches(userId);
if (matches.length === 0) {
await this.bot.sendMessage(
msg.chat.id,
'<27> У вас пока нет матчей.\n\n' +
'🔍 Попробуйте просмотреть больше анкет!\n' +
'Используйте /browse для поиска.'
);
return;
}
let matchText = `💕 Ваши матчи (${matches.length}):\n\n`;
for (const match of matches) {
const otherUserId = match.userId1 === userId ? match.userId2 : match.userId1;
const otherProfile = await this.profileService.getProfileByUserId(otherUserId);
if (otherProfile) {
matchText += `💖 ${otherProfile.name}, ${otherProfile.age}\n`;
matchText += `📍 ${otherProfile.city || 'Не указан'}\n`;
matchText += `💌 Матч: ${new Date(match.createdAt).toLocaleDateString()}\n\n`;
}
}
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[{ text: '💬 Открыть чаты', callback_data: 'open_chats' }],
[{ text: '🔍 Найти еще', callback_data: 'start_browsing' }]
]
};
await this.bot.sendMessage(msg.chat.id, matchText, { reply_markup: keyboard });
}
async handleSettings(msg: Message): Promise<void> {
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '🔍 Настройки поиска', callback_data: 'search_settings' },
{ text: '🔔 Уведомления', callback_data: 'notification_settings' }
],
[
{ text: '🚫 Скрыть профиль', callback_data: 'hide_profile' },
{ text: '🗑 Удалить профиль', callback_data: 'delete_profile' }
]
]
};
await this.bot.sendMessage(
msg.chat.id,
'⚙️ Настройки профиля\n\nВыберите что хотите изменить:',
{ reply_markup: keyboard }
);
}
async handleCreateProfile(msg: Message): Promise<void> {
const userId = msg.from?.id.toString();
if (!userId) return;
await this.bot.sendMessage(
msg.chat.id,
'👋 Давайте создадим ваш профиль!\n\n' +
'📝 Сначала напишите ваше имя:'
);
// Устанавливаем состояние ожидания имени
// Это будет обрабатываться в messageHandlers
}
// Вспомогательные методы
async showUserProfile(chatId: number, profile: Profile, isOwner: boolean = false): Promise<void> {
const mainPhotoFileId = profile.photos[0]; // Первое фото - главное
let profileText = `👤 ${profile.name}, ${profile.age}\n`;
profileText += `📍 ${profile.city || 'Не указан'}\n`;
if (profile.job) profileText += `💼 ${profile.job}\n`;
if (profile.education) profileText += `🎓 ${profile.education}\n`;
if (profile.height) profileText += `📏 ${profile.height} см\n`;
profileText += `\n📝 ${profile.bio || 'Описание не указано'}\n`;
if (profile.interests.length > 0) {
profileText += `\n🎯 Интересы: ${profile.interests.join(', ')}`;
}
const keyboard: InlineKeyboardMarkup = isOwner ? {
inline_keyboard: [
[
{ text: '✏️ Редактировать', callback_data: 'edit_profile' },
{ text: '📸 Фото', callback_data: 'manage_photos' }
],
[{ text: '🔍 Начать поиск', callback_data: 'start_browsing' }]
]
} : {
inline_keyboard: [
[{ text: '👈 Назад', callback_data: 'back_to_browsing' }]
]
};
if (mainPhotoFileId) {
await this.bot.sendPhoto(chatId, mainPhotoFileId, {
caption: profileText,
reply_markup: keyboard
});
} else {
await this.bot.sendMessage(chatId, profileText, { reply_markup: keyboard });
}
}
async showNextCandidate(chatId: number, userId: string): Promise<void> {
const candidate = await this.matchingService.getNextCandidate(userId);
if (!candidate) {
await this.bot.sendMessage(
chatId,
'🎉 Вы просмотрели всех доступных кандидатов!\n\n' +
'⏰ Попробуйте позже - возможно появятся новые анкеты!'
);
return;
}
const candidatePhotoFileId = candidate.photos[0]; // Первое фото - главное
let candidateText = `${candidate.name}, ${candidate.age}\n`;
candidateText += `📍 ${candidate.city || 'Не указан'}\n`;
if (candidate.job) candidateText += `💼 ${candidate.job}\n`;
if (candidate.education) candidateText += `🎓 ${candidate.education}\n`;
if (candidate.height) candidateText += `📏 ${candidate.height} см\n`;
candidateText += `\n📝 ${candidate.bio || 'Описание отсутствует'}\n`;
if (candidate.interests.length > 0) {
candidateText += `\n🎯 Интересы: ${candidate.interests.join(', ')}`;
}
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '👎 Не нравится', callback_data: `dislike_${candidate.userId}` },
{ text: '💖 Супер лайк', callback_data: `superlike_${candidate.userId}` },
{ text: '👍 Нравится', callback_data: `like_${candidate.userId}` }
],
[
{ text: '👤 Профиль', callback_data: `view_profile_${candidate.userId}` },
{ text: '📸 Еще фото', callback_data: `more_photos_${candidate.userId}` }
],
[{ text: '⏭ Следующий', callback_data: 'next_candidate' }]
]
};
if (candidatePhotoFileId) {
await this.bot.sendPhoto(chatId, candidatePhotoFileId, {
caption: candidateText,
reply_markup: keyboard
});
} else {
await this.bot.sendMessage(chatId, candidateText, { reply_markup: keyboard });
}
}
}

View File

@@ -0,0 +1,315 @@
import TelegramBot, { Message, InlineKeyboardMarkup } from 'node-telegram-bot-api';
import { ProfileService } from '../services/profileService';
import { ChatService } from '../services/chatService';
// Состояния пользователей для создания профилей
interface UserState {
step: string;
data: any;
}
// Состояния пользователей для чатов
interface ChatState {
waitingForMessage: boolean;
matchId: string;
}
export class MessageHandlers {
private bot: TelegramBot;
private profileService: ProfileService;
private chatService: ChatService;
private userStates: Map<string, UserState> = new Map();
private chatStates: Map<string, ChatState> = new Map();
constructor(bot: TelegramBot) {
this.bot = bot;
this.profileService = new ProfileService();
this.chatService = new ChatService();
}
register(): void {
this.bot.on('message', (msg: Message) => {
// Игнорируем команды (они обрабатываются CommandHandlers)
if (!msg.text?.startsWith('/')) {
this.handleMessage(msg);
}
});
}
async handleMessage(msg: Message): Promise<void> {
const userId = msg.from?.id.toString();
if (!userId) return;
const userState = this.userStates.get(userId);
const chatState = this.chatStates.get(userId);
// Если пользователь в процессе отправки сообщения в чат
if (chatState?.waitingForMessage && msg.text) {
await this.handleChatMessage(msg, userId, chatState.matchId);
return;
}
// Если пользователь в процессе создания профиля
if (userState) {
await this.handleProfileCreation(msg, userId, userState);
return;
}
// Обычные сообщения
if (msg.text) {
await this.bot.sendMessage(
msg.chat.id,
'Привет! 👋\n\n' +
'Используйте команды для навигации:\n' +
'/start - Главное меню\n' +
'/help - Справка\n' +
'/profile - Мой профиль\n' +
'/browse - Поиск анкет'
);
} else if (msg.photo) {
// Обработка фотографий (для добавления в профиль)
await this.handlePhoto(msg, userId);
}
}
// Обработка создания профиля
async handleProfileCreation(msg: Message, userId: string, userState: UserState): Promise<void> {
const chatId = msg.chat.id;
try {
switch (userState.step) {
case 'waiting_name':
if (!msg.text) {
await this.bot.sendMessage(chatId, '❌ Пожалуйста, отправьте текстовое сообщение с вашим именем');
return;
}
userState.data.name = msg.text.trim();
userState.step = 'waiting_age';
await this.bot.sendMessage(chatId, '📅 Отлично! Теперь укажите ваш возраст:');
break;
case 'waiting_age':
if (!msg.text) {
await this.bot.sendMessage(chatId, '❌ Пожалуйста, отправьте число');
return;
}
const age = parseInt(msg.text.trim());
if (isNaN(age) || age < 18 || age > 100) {
await this.bot.sendMessage(chatId, '❌ Возраст должен быть числом от 18 до 100');
return;
}
userState.data.age = age;
userState.step = 'waiting_city';
await this.bot.sendMessage(chatId, '📍 Прекрасно! В каком городе вы живете?');
break;
case 'waiting_city':
if (!msg.text) {
await this.bot.sendMessage(chatId, '❌ Пожалуйста, отправьте название города');
return;
}
userState.data.city = msg.text.trim();
userState.step = 'waiting_bio';
await this.bot.sendMessage(
chatId,
'📝 Теперь расскажите немного о себе (био):\n\n' +
'💡 Например: хобби, интересы, что ищете в отношениях и т.д.'
);
break;
case 'waiting_bio':
if (!msg.text) {
await this.bot.sendMessage(chatId, '❌ Пожалуйста, отправьте текстовое описание');
return;
}
userState.data.bio = msg.text.trim();
userState.step = 'waiting_photo';
await this.bot.sendMessage(
chatId,
'📸 Отлично! Теперь отправьте ваше фото:\n\n' +
'💡 Лучше использовать качественное фото лица'
);
break;
case 'waiting_photo':
if (!msg.photo) {
await this.bot.sendMessage(chatId, '❌ Пожалуйста, отправьте фотографию');
return;
}
// Получаем самое большое фото
const photo = msg.photo[msg.photo.length - 1];
userState.data.photos = [photo.file_id]; // Просто массив file_id
// Создаем профиль
await this.createProfile(chatId, userId, userState.data);
break;
default:
this.userStates.delete(userId);
break;
}
} catch (error) {
console.error('Profile creation error:', error);
await this.bot.sendMessage(chatId, '❌ Произошла ошибка. Попробуйте еще раз.');
this.userStates.delete(userId);
}
}
// Создание профиля в базе данных
async createProfile(chatId: number, telegramId: string, profileData: any): Promise<void> {
try {
// Сначала создаем пользователя если не существует
const userId = await this.profileService.ensureUser(telegramId, {
username: '', // Можно получить из Telegram API если нужно
first_name: profileData.name,
last_name: ''
});
// Определяем интересы по умолчанию
const interestedIn = profileData.gender === 'male' ? 'female' :
profileData.gender === 'female' ? 'male' : 'both';
const newProfile = await this.profileService.createProfile(userId, {
name: profileData.name,
age: profileData.age,
gender: profileData.gender,
interestedIn: interestedIn,
bio: profileData.bio,
city: profileData.city,
photos: profileData.photos,
interests: [],
searchPreferences: {
minAge: Math.max(18, profileData.age - 10),
maxAge: Math.min(100, profileData.age + 10),
maxDistance: 50
}
});
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: [
[
{ text: '👤 Мой профиль', callback_data: 'view_my_profile' },
{ text: '🔍 Начать поиск', callback_data: 'start_browsing' }
],
[{ text: '⚙️ Настройки', callback_data: 'settings' }]
]
};
await this.bot.sendMessage(
chatId,
`🎉 Профиль успешно создан!\n\n` +
`Добро пожаловать, ${profileData.name}! 💖\n\n` +
`Теперь вы можете начать поиск своей второй половинки!`,
{ reply_markup: keyboard }
);
// Удаляем состояние пользователя
this.userStates.delete(telegramId);
} catch (error) {
console.error('Error creating profile:', error);
await this.bot.sendMessage(
chatId,
'❌ Ошибка при создании профиля. Попробуйте еще раз позже.'
);
this.userStates.delete(telegramId);
}
}
// Обработка фотографий
async handlePhoto(msg: Message, userId: string): Promise<void> {
const userState = this.userStates.get(userId);
if (userState && userState.step === 'waiting_photo') {
// Фото для создания профиля - обрабатывается выше
return;
}
// Фото для существующего профиля
await this.bot.sendMessage(
msg.chat.id,
'📸 Для управления фотографиями используйте:\n' +
'/profile - затем "📸 Фото"'
);
}
// Методы для инициализации создания профиля
startProfileCreation(userId: string, gender: string): void {
this.userStates.set(userId, {
step: 'waiting_name',
data: { gender }
});
}
// Получить состояние пользователя
getUserState(userId: string): UserState | undefined {
return this.userStates.get(userId);
}
// Очистить состояние пользователя
clearUserState(userId: string): void {
this.userStates.delete(userId);
}
// Методы для управления чатами
setWaitingForMessage(userId: string, matchId: string): void {
this.chatStates.set(userId, {
waitingForMessage: true,
matchId
});
}
clearChatState(userId: string): void {
this.chatStates.delete(userId);
}
// Обработка сообщения в чате
async handleChatMessage(msg: Message, userId: string, matchId: string): Promise<void> {
if (!msg.text) {
await this.bot.sendMessage(msg.chat.id, '❌ Поддерживаются только текстовые сообщения');
return;
}
// Отправляем сообщение
const message = await this.chatService.sendMessage(matchId, userId, msg.text);
if (message) {
await this.bot.sendMessage(
msg.chat.id,
'✅ Сообщение отправлено!\n\n' +
`💬 "${msg.text}"`
);
// Очищаем состояние чата
this.clearChatState(userId);
// Возвращаемся к чату
setTimeout(async () => {
const keyboard = {
inline_keyboard: [
[{ text: '← Вернуться к чату', callback_data: `chat_${matchId}` }],
[{ text: '💬 Все чаты', callback_data: 'open_chats' }]
]
};
await this.bot.sendMessage(
msg.chat.id,
'💬 Что дальше?',
{ reply_markup: keyboard }
);
}, 1500);
} else {
await this.bot.sendMessage(msg.chat.id, '❌ Не удалось отправить сообщение. Попробуйте еще раз.');
}
}
}

143
src/models/Match.ts Normal file
View File

@@ -0,0 +1,143 @@
export interface MatchData {
id: string;
userId1: string;
userId2: string;
createdAt: Date;
lastMessageAt?: Date;
isActive: boolean;
isSuperMatch: boolean;
unreadCount1: number; // Непрочитанные сообщения для user1
unreadCount2: number; // Непрочитанные сообщения для user2
}
export interface MessageData {
id: string;
matchId: string;
senderId: string;
receiverId: string;
content: string;
messageType: 'text' | 'photo' | 'gif' | 'sticker';
timestamp: Date;
isRead: boolean;
}
export class Match {
id: string;
userId1: string;
userId2: string;
createdAt: Date;
lastMessageAt?: Date;
isActive: boolean;
isSuperMatch: boolean;
unreadCount1: number;
unreadCount2: number;
messages: MessageData[];
constructor(data: MatchData) {
this.id = data.id;
this.userId1 = data.userId1;
this.userId2 = data.userId2;
this.createdAt = data.createdAt;
this.lastMessageAt = data.lastMessageAt;
this.isActive = data.isActive !== false;
this.isSuperMatch = data.isSuperMatch || false;
this.unreadCount1 = data.unreadCount1 || 0;
this.unreadCount2 = data.unreadCount2 || 0;
this.messages = [];
}
// Получить детали матча
getMatchDetails() {
return {
id: this.id,
userId1: this.userId1,
userId2: this.userId2,
createdAt: this.createdAt,
lastMessageAt: this.lastMessageAt,
isActive: this.isActive,
isSuperMatch: this.isSuperMatch,
messageCount: this.messages.length
};
}
// Получить ID другого пользователя в матче
getOtherUserId(currentUserId: string): string {
return this.userId1 === currentUserId ? this.userId2 : this.userId1;
}
// Добавить сообщение
addMessage(message: MessageData): void {
this.messages.push(message);
this.lastMessageAt = message.timestamp;
// Увеличить счетчик непрочитанных для получателя
if (message.receiverId === this.userId1) {
this.unreadCount1++;
} else {
this.unreadCount2++;
}
}
// Отметить сообщения как прочитанные
markAsRead(userId: string): void {
if (userId === this.userId1) {
this.unreadCount1 = 0;
} else {
this.unreadCount2 = 0;
}
// Отметить сообщения как прочитанные
this.messages.forEach(message => {
if (message.receiverId === userId) {
message.isRead = true;
}
});
}
// Получить количество непрочитанных сообщений для пользователя
getUnreadCount(userId: string): number {
return userId === this.userId1 ? this.unreadCount1 : this.unreadCount2;
}
// Получить последние сообщения
getRecentMessages(limit: number = 50): MessageData[] {
return this.messages
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, limit);
}
// Получить последнее сообщение
getLastMessage(): MessageData | undefined {
if (this.messages.length === 0) return undefined;
return this.messages.reduce((latest, current) =>
current.timestamp > latest.timestamp ? current : latest
);
}
// Деактивировать матч (размэтч)
deactivate(): void {
this.isActive = false;
}
// Проверить, участвует ли пользователь в матче
includesUser(userId: string): boolean {
return this.userId1 === userId || this.userId2 === userId;
}
// Получить краткую информацию для списка матчей
getSummary(currentUserId: string) {
const lastMessage = this.getLastMessage();
return {
id: this.id,
otherUserId: this.getOtherUserId(currentUserId),
lastMessage: lastMessage ? {
content: lastMessage.content,
timestamp: lastMessage.timestamp,
isFromMe: lastMessage.senderId === currentUserId
} : null,
unreadCount: this.getUnreadCount(currentUserId),
isSuperMatch: this.isSuperMatch,
createdAt: this.createdAt
};
}
}

30
src/models/Message.ts Normal file
View File

@@ -0,0 +1,30 @@
export class Message {
id: string;
matchId: string;
senderId: string;
content: string;
messageType: 'text' | 'photo' | 'video' | 'voice' | 'sticker' | 'gif';
fileId?: string;
isRead: boolean;
createdAt: Date;
constructor(data: {
id: string;
matchId: string;
senderId: string;
content: string;
messageType: 'text' | 'photo' | 'video' | 'voice' | 'sticker' | 'gif';
fileId?: string;
isRead: boolean;
createdAt: Date;
}) {
this.id = data.id;
this.matchId = data.matchId;
this.senderId = data.senderId;
this.content = data.content;
this.messageType = data.messageType;
this.fileId = data.fileId;
this.isRead = data.isRead;
this.createdAt = data.createdAt;
}
}

178
src/models/Profile.ts Normal file
View File

@@ -0,0 +1,178 @@
export interface ProfileData {
userId: string;
name: string;
age: number;
gender: 'male' | 'female' | 'other';
interestedIn: 'male' | 'female' | 'both';
bio?: string;
photos: string[]; // Просто массив file_id
interests: string[];
city?: string;
education?: string;
job?: string;
height?: number;
location?: {
latitude: number;
longitude: number;
};
searchPreferences: {
minAge: number;
maxAge: number;
maxDistance: number;
};
isVerified: boolean;
isVisible: boolean;
createdAt: Date;
updatedAt: Date;
}
export class Profile {
userId: string;
name: string;
age: number;
gender: 'male' | 'female' | 'other';
interestedIn: 'male' | 'female' | 'both';
bio?: string;
photos: string[];
interests: string[];
city?: string;
education?: string;
job?: string;
height?: number;
location?: {
latitude: number;
longitude: number;
};
searchPreferences: {
minAge: number;
maxAge: number;
maxDistance: number;
};
isVerified: boolean;
isVisible: boolean;
createdAt: Date;
updatedAt: Date;
constructor(data: ProfileData) {
this.userId = data.userId;
this.name = data.name;
this.age = data.age;
this.gender = data.gender;
this.interestedIn = data.interestedIn;
this.bio = data.bio;
this.photos = data.photos || [];
this.interests = data.interests || [];
this.city = data.city;
this.education = data.education;
this.job = data.job;
this.height = data.height;
this.location = data.location;
this.searchPreferences = data.searchPreferences || {
minAge: 18,
maxAge: 50,
maxDistance: 50
};
this.isVerified = data.isVerified || false;
this.isVisible = data.isVisible !== false;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
// Обновить профиль
updateProfile(updates: Partial<ProfileData>): void {
Object.assign(this, updates);
this.updatedAt = new Date();
}
// Добавить фото
addPhoto(photoFileId: string): void {
this.photos.push(photoFileId);
this.updatedAt = new Date();
}
// Удалить фото
removePhoto(photoFileId: string): void {
this.photos = this.photos.filter(photo => photo !== photoFileId);
this.updatedAt = new Date();
}
// Установить главное фото
setMainPhoto(photoFileId: string): void {
// Перемещаем фото в начало массива
this.photos = this.photos.filter(photo => photo !== photoFileId);
this.photos.unshift(photoFileId);
this.updatedAt = new Date();
}
// Получить главное фото
getMainPhoto(): string | undefined {
return this.photos[0];
}
// Получить профиль для показа
getDisplayProfile() {
return {
userId: this.userId,
name: this.name,
age: this.age,
bio: this.bio,
photos: this.photos,
interests: this.interests,
city: this.city,
education: this.education,
job: this.job,
height: this.height,
isVerified: this.isVerified
};
}
// Проверить, подходит ли профиль для показа другому пользователю
isVisibleTo(otherProfile: Profile): boolean {
if (!this.isVisible) return false;
// Проверка возрастных предпочтений
if (otherProfile.age < this.searchPreferences.minAge ||
otherProfile.age > this.searchPreferences.maxAge) {
return false;
}
// Проверка гендерных предпочтений
if (this.interestedIn !== 'both' && this.interestedIn !== otherProfile.gender) {
return false;
}
return true;
}
// Проверить совместимость профилей
isCompatibleWith(otherProfile: Profile): boolean {
return this.isVisibleTo(otherProfile) && otherProfile.isVisibleTo(this);
}
// Получить расстояние до другого профиля
getDistanceTo(otherProfile: Profile): number | null {
if (!this.location || !otherProfile.location) return null;
const R = 6371; // Радиус Земли в км
const dLat = (otherProfile.location.latitude - this.location.latitude) * Math.PI / 180;
const dLon = (otherProfile.location.longitude - this.location.longitude) * Math.PI / 180;
const a =
Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(this.location.latitude * Math.PI / 180) * Math.cos(otherProfile.location.latitude * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
// Валидация профиля
isComplete(): boolean {
return !!(
this.name &&
this.age >= 18 &&
this.gender &&
this.interestedIn &&
this.photos.length > 0 &&
this.bio
);
}
}

55
src/models/Swipe.ts Normal file
View File

@@ -0,0 +1,55 @@
export type SwipeType = 'like' | 'pass' | 'superlike';
export interface SwipeData {
id: string;
userId: string;
targetUserId: string;
type: SwipeType;
timestamp: Date;
isMatch?: boolean;
}
export class Swipe {
id: string;
userId: string;
targetUserId: string;
type: SwipeType;
timestamp: Date;
isMatch: boolean;
constructor(data: SwipeData) {
this.id = data.id;
this.userId = data.userId;
this.targetUserId = data.targetUserId;
this.type = data.type;
this.timestamp = data.timestamp;
this.isMatch = data.isMatch || false;
}
// Получить информацию о свайпе
getSwipeInfo() {
return {
id: this.id,
userId: this.userId,
targetUserId: this.targetUserId,
type: this.type,
timestamp: this.timestamp,
isMatch: this.isMatch
};
}
// Проверить, является ли свайп лайком
isLike(): boolean {
return this.type === 'like' || this.type === 'superlike';
}
// Проверить, является ли свайп суперлайком
isSuperLike(): boolean {
return this.type === 'superlike';
}
// Установить статус матча
setMatch(isMatch: boolean): void {
this.isMatch = isMatch;
}
}

70
src/models/User.ts Normal file
View File

@@ -0,0 +1,70 @@
export interface UserData {
id: string;
telegramId: number;
username?: string;
firstName?: string;
lastName?: string;
languageCode?: string;
isActive: boolean;
createdAt: Date;
lastActiveAt: Date;
}
export class User {
id: string;
telegramId: number;
username?: string;
firstName?: string;
lastName?: string;
languageCode?: string;
isActive: boolean;
createdAt: Date;
lastActiveAt: Date;
constructor(data: UserData) {
this.id = data.id;
this.telegramId = data.telegramId;
this.username = data.username;
this.firstName = data.firstName;
this.lastName = data.lastName;
this.languageCode = data.languageCode || 'en';
this.isActive = data.isActive;
this.createdAt = data.createdAt;
this.lastActiveAt = data.lastActiveAt;
}
// Метод для получения информации о пользователе
getUserInfo() {
return {
id: this.id,
telegramId: this.telegramId,
username: this.username,
firstName: this.firstName,
lastName: this.lastName,
fullName: this.getFullName(),
isActive: this.isActive
};
}
// Получить полное имя пользователя
getFullName(): string {
const parts = [this.firstName, this.lastName].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : this.username || `User ${this.telegramId}`;
}
// Обновить время последней активности
updateLastActive(): void {
this.lastActiveAt = new Date();
}
// Деактивировать пользователя
deactivate(): void {
this.isActive = false;
}
// Активировать пользователя
activate(): void {
this.isActive = true;
this.updateLastActive();
}
}

107
src/scripts/initDb.ts Normal file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env ts-node
import { initializeDatabase, testConnection, closePool } from '../database/connection';
async function main() {
console.log('🚀 Initializing database...');
try {
// Проверяем подключение
const connected = await testConnection();
if (!connected) {
console.error('❌ Failed to connect to database');
process.exit(1);
}
// Инициализируем схему
await initializeDatabase();
console.log('✅ Database initialized successfully');
// Создаем дополнительные таблицы, если нужно
await createAdditionalTables();
console.log('✅ Additional tables created');
} catch (error) {
console.error('❌ Database initialization failed:', error);
process.exit(1);
} finally {
await closePool();
console.log('👋 Database connection closed');
}
}
async function createAdditionalTables() {
const { query } = await import('../database/connection');
// Таблица для уведомлений
await query(`
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
data JSONB DEFAULT '{}',
is_read BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
);
`);
// Таблица для запланированных уведомлений
await query(`
CREATE TABLE IF NOT EXISTS scheduled_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
data JSONB DEFAULT '{}',
scheduled_at TIMESTAMP NOT NULL,
sent BOOLEAN DEFAULT false,
sent_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
`);
// Таблица для отчетов и блокировок
await query(`
CREATE TABLE IF NOT EXISTS reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id UUID REFERENCES users(id) ON DELETE CASCADE,
reported_id UUID REFERENCES users(id) ON DELETE CASCADE,
reason VARCHAR(100) NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW(),
resolved_at TIMESTAMP
);
`);
// Таблица для блокировок
await query(`
CREATE TABLE IF NOT EXISTS blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
blocker_id UUID REFERENCES users(id) ON DELETE CASCADE,
blocked_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(blocker_id, blocked_id)
);
`);
// Добавляем недостающие поля в users
await query(`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS notification_settings JSONB DEFAULT '{"newMatches": true, "newMessages": true, "newLikes": true, "reminders": true}';
`);
// Индексы для производительности
await query(`
CREATE INDEX IF NOT EXISTS idx_notifications_user_type ON notifications(user_id, type);
CREATE INDEX IF NOT EXISTS idx_scheduled_notifications_time ON scheduled_notifications(scheduled_at, sent);
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status);
CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON blocks(blocker_id);
`);
}
// Запуск скрипта
if (require.main === module) {
main();
}
export { main as initializeDB };

257
src/services/chatService.ts Normal file
View File

@@ -0,0 +1,257 @@
import { query } from '../database/connection';
import { Message } from '../models/Message';
import { Match } from '../models/Match';
import { ProfileService } from './profileService';
import { v4 as uuidv4 } from 'uuid';
export class ChatService {
private profileService: ProfileService;
constructor() {
this.profileService = new ProfileService();
}
// Получить все чаты (матчи) пользователя
async getUserChats(telegramId: string): Promise<any[]> {
try {
// Сначала получаем userId по telegramId
const userId = await this.profileService.getUserIdByTelegramId(telegramId);
if (!userId) {
return [];
}
const result = await query(`
SELECT
m.*,
CASE
WHEN m.user1_id = $1 THEN m.user2_id
ELSE m.user1_id
END as other_user_id,
p.name as other_user_name,
p.photos as other_user_photos,
msg.content as last_message_content,
msg.created_at as last_message_time,
msg.sender_id as last_message_sender_id,
(
SELECT COUNT(*)
FROM messages msg2
WHERE msg2.match_id = m.id
AND msg2.sender_id != $1
AND msg2.is_read = false
) as unread_count
FROM matches m
LEFT JOIN profiles p ON (
CASE
WHEN m.user1_id = $1 THEN p.user_id = m.user2_id
ELSE p.user_id = m.user1_id
END
)
LEFT JOIN messages msg ON msg.id = (
SELECT id FROM messages
WHERE match_id = m.id
ORDER BY created_at DESC
LIMIT 1
)
WHERE (m.user1_id = $1 OR m.user2_id = $1)
AND m.status = 'active'
ORDER BY
CASE WHEN msg.created_at IS NULL THEN m.matched_at ELSE msg.created_at END DESC
`, [userId]);
return result.rows.map((row: any) => ({
matchId: row.id,
otherUserId: row.other_user_id,
otherUserName: row.other_user_name,
otherUserPhoto: row.other_user_photos?.[0] || null,
lastMessage: row.last_message_content,
lastMessageTime: row.last_message_time || row.matched_at,
lastMessageFromMe: row.last_message_sender_id === userId,
unreadCount: parseInt(row.unread_count) || 0,
matchedAt: row.matched_at
}));
} catch (error) {
console.error('Error getting user chats:', error);
return [];
}
}
// Получить сообщения в чате
async getChatMessages(matchId: string, limit: number = 50, offset: number = 0): Promise<Message[]> {
try {
const result = await query(`
SELECT * FROM messages
WHERE match_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, [matchId, limit, offset]);
return result.rows.map((row: any) => new Message({
id: row.id,
matchId: row.match_id,
senderId: row.sender_id,
content: row.content,
messageType: row.message_type,
fileId: row.file_id,
isRead: row.is_read,
createdAt: new Date(row.created_at)
})).reverse(); // Возвращаем в хронологическом порядке
} catch (error) {
console.error('Error getting chat messages:', error);
return [];
}
}
// Отправить сообщение
async sendMessage(
matchId: string,
senderTelegramId: string,
content: string,
messageType: 'text' | 'photo' | 'video' | 'voice' | 'sticker' | 'gif' = 'text',
fileId?: string
): Promise<Message | null> {
try {
// Получаем senderId по telegramId
const senderId = await this.profileService.getUserIdByTelegramId(senderTelegramId);
if (!senderId) {
throw new Error('Sender not found');
}
// Проверяем, что матч активен и пользователь является участником
const matchResult = await query(`
SELECT * FROM matches
WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) AND status = 'active'
`, [matchId, senderId]);
if (matchResult.rows.length === 0) {
throw new Error('Match not found or not accessible');
}
const messageId = uuidv4();
// Создаем сообщение
await query(`
INSERT INTO messages (id, match_id, sender_id, content, message_type, file_id, is_read, created_at)
VALUES ($1, $2, $3, $4, $5, $6, false, CURRENT_TIMESTAMP)
`, [messageId, matchId, senderId, content, messageType, fileId]);
// Обновляем время последнего сообщения в матче
await query(`
UPDATE matches
SET last_message_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [matchId]);
// Получаем созданное сообщение
const messageResult = await query(`
SELECT * FROM messages WHERE id = $1
`, [messageId]);
if (messageResult.rows.length === 0) {
return null;
}
const row = messageResult.rows[0];
return new Message({
id: row.id,
matchId: row.match_id,
senderId: row.sender_id,
content: row.content,
messageType: row.message_type,
fileId: row.file_id,
isRead: row.is_read,
createdAt: new Date(row.created_at)
});
} catch (error) {
console.error('Error sending message:', error);
return null;
}
}
// Отметить сообщения как прочитанные
async markMessagesAsRead(matchId: string, readerTelegramId: string): Promise<void> {
try {
const readerId = await this.profileService.getUserIdByTelegramId(readerTelegramId);
if (!readerId) {
return;
}
await query(`
UPDATE messages
SET is_read = true
WHERE match_id = $1 AND sender_id != $2 AND is_read = false
`, [matchId, readerId]);
} catch (error) {
console.error('Error marking messages as read:', error);
}
}
// Получить информацию о матче
async getMatchInfo(matchId: string, userTelegramId: string): Promise<any | null> {
try {
const userId = await this.profileService.getUserIdByTelegramId(userTelegramId);
if (!userId) {
return null;
}
const result = await query(`
SELECT
m.*,
CASE
WHEN m.user1_id = $2 THEN m.user2_id
ELSE m.user1_id
END as other_user_id
FROM matches m
WHERE m.id = $1 AND (m.user1_id = $2 OR m.user2_id = $2) AND m.status = 'active'
`, [matchId, userId]);
if (result.rows.length === 0) {
return null;
}
const match = result.rows[0];
const otherUserProfile = await this.profileService.getProfileByUserId(match.other_user_id);
return {
matchId: match.id,
otherUserId: match.other_user_id,
otherUserProfile,
matchedAt: match.matched_at
};
} catch (error) {
console.error('Error getting match info:', error);
return null;
}
}
// Удалить матч (размэтчиться)
async unmatch(matchId: string, userTelegramId: string): Promise<boolean> {
try {
const userId = await this.profileService.getUserIdByTelegramId(userTelegramId);
if (!userId) {
return false;
}
// Проверяем, что пользователь является участником матча
const matchResult = await query(`
SELECT * FROM matches
WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) AND status = 'active'
`, [matchId, userId]);
if (matchResult.rows.length === 0) {
return false;
}
// Помечаем матч как неактивный
await query(`
UPDATE matches
SET status = 'unmatched'
WHERE id = $1
`, [matchId]);
return true;
} catch (error) {
console.error('Error unmatching:', error);
return false;
}
}
}

View File

@@ -0,0 +1,384 @@
import { v4 as uuidv4 } from 'uuid';
import { query, transaction } from '../database/connection';
import { Swipe, SwipeData, SwipeType } from '../models/Swipe';
import { Match, MatchData } from '../models/Match';
import { Profile } from '../models/Profile';
import { ProfileService } from './profileService';
import { NotificationService } from './notificationService';
import { BotError } from '../types';
export class MatchingService {
private profileService: ProfileService;
private notificationService: NotificationService;
constructor() {
this.profileService = new ProfileService();
this.notificationService = new NotificationService();
}
// Выполнить свайп
// Конвертация типов свайпов между API и БД
private convertSwipeTypeToDirection(swipeType: SwipeType): string {
switch (swipeType) {
case 'like': return 'right';
case 'pass': return 'left';
case 'superlike': return 'super';
default: return 'left';
}
}
private convertDirectionToSwipeType(direction: string): SwipeType {
switch (direction) {
case 'right': return 'like';
case 'left': return 'pass';
case 'super': return 'superlike';
default: return 'pass';
}
}
async performSwipe(telegramId: string, targetTelegramId: string, swipeType: SwipeType): Promise<{
swipe: Swipe;
isMatch: boolean;
match?: Match;
}> {
// Получить профили пользователей
const userProfile = await this.profileService.getProfileByTelegramId(telegramId);
const targetProfile = await this.profileService.getProfileByUserId(targetTelegramId); if (!userProfile || !targetProfile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 400);
}
const userId = userProfile.userId;
const targetUserId = targetProfile.userId;
// Проверяем, что пользователь не свайпает сам себя
if (userId === targetUserId) {
throw new BotError('Cannot swipe yourself', 'INVALID_SWIPE');
}
// Проверяем, что свайп еще не был сделан
const existingSwipe = await this.getSwipe(userId, targetUserId);
if (existingSwipe) {
throw new BotError('Already swiped this profile', 'ALREADY_SWIPED');
}
const swipeId = uuidv4();
const direction = this.convertSwipeTypeToDirection(swipeType);
let isMatch = false;
let match: Match | undefined;
await transaction(async (client) => {
// Создаем свайп
await client.query(`
INSERT INTO swipes (id, swiper_id, swiped_id, direction, created_at)
VALUES ($1, $2, $3, $4, $5)
`, [swipeId, userId, targetUserId, direction, new Date()]);
// Если это лайк или суперлайк, проверяем взаимность
if (swipeType === 'like' || swipeType === 'superlike') {
const reciprocalSwipe = await client.query(`
SELECT * FROM swipes
WHERE swiper_id = $1 AND swiped_id = $2 AND direction IN ('like', 'super')
`, [targetUserId, userId]);
if (reciprocalSwipe.rows.length > 0) {
isMatch = true;
const matchId = uuidv4();
const isSuperMatch = swipeType === 'superlike' || reciprocalSwipe.rows[0].direction === 'super';
// Создаем матч
await client.query(`
INSERT INTO matches (id, user1_id, user2_id, matched_at, status)
VALUES ($1, $2, $3, $4, $5)
`, [matchId, userId, targetUserId, new Date(), 'active']);
match = new Match({
id: matchId,
userId1: userId,
userId2: targetUserId,
createdAt: new Date(),
isActive: true,
isSuperMatch: false,
unreadCount1: 0,
unreadCount2: 0
});
}
}
});
const swipe = new Swipe({
id: swipeId,
userId,
targetUserId,
type: swipeType,
timestamp: new Date(),
isMatch
});
// Отправляем уведомления
if (swipeType === 'like' || swipeType === 'superlike') {
this.notificationService.sendLikeNotification(targetTelegramId, telegramId, swipeType === 'superlike');
}
if (isMatch && match) {
this.notificationService.sendMatchNotification(userId, targetUserId);
this.notificationService.sendMatchNotification(targetUserId, userId);
}
return { swipe, isMatch, match };
}
// Получить свайп между двумя пользователями
async getSwipe(userId: string, targetUserId: string): Promise<Swipe | null> {
const result = await query(`
SELECT * FROM swipes
WHERE swiper_id = $1 AND swiped_id = $2
`, [userId, targetUserId]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToSwipe(result.rows[0]);
}
// Получить все матчи пользователя по telegram ID
async getUserMatches(telegramId: string, limit: number = 50): Promise<Match[]> {
// Сначала получаем userId по telegramId
const userId = await this.profileService.getUserIdByTelegramId(telegramId);
if (!userId) {
return [];
}
const result = await query(`
SELECT * FROM matches
WHERE (user1_id = $1 OR user2_id = $1) AND status = 'active'
ORDER BY matched_at DESC
LIMIT $2
`, [userId, limit]);
return result.rows.map((row: any) => this.mapEntityToMatch(row));
}
// Получить матч по ID
async getMatchById(matchId: string): Promise<Match | null> {
const result = await query(`
SELECT * FROM matches WHERE id = $1
`, [matchId]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToMatch(result.rows[0]);
}
// Получить матч между двумя пользователями
async getMatchBetweenUsers(userId1: string, userId2: string): Promise<Match | null> {
const result = await query(`
SELECT * FROM matches
WHERE ((user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1))
AND is_active = true
`, [userId1, userId2]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToMatch(result.rows[0]);
}
// Размэтчить (деактивировать матч)
async unmatch(userId: string, matchId: string): Promise<boolean> {
const match = await this.getMatchById(matchId);
if (!match || !match.includesUser(userId)) {
throw new BotError('Match not found or access denied', 'MATCH_NOT_FOUND');
}
await query(`
UPDATE matches SET is_active = false WHERE id = $1
`, [matchId]);
return true;
}
// Получить недавние лайки
async getRecentLikes(userId: string, limit: number = 20): Promise<Swipe[]> {
const result = await query(`
SELECT * FROM swipes
WHERE swiped_id = $1 AND direction IN ('like', 'super') AND is_match = false
ORDER BY created_at DESC
LIMIT $2
`, [userId, limit]);
return result.rows.map((row: any) => this.mapEntityToSwipe(row));
}
// Получить статистику свайпов пользователя за день
async getDailySwipeStats(userId: string): Promise<{
likes: number;
superlikes: number;
passes: number;
total: number;
}> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const result = await query(`
SELECT direction, COUNT(*) as count
FROM swipes
WHERE swiper_id = $1 AND created_at >= $2
GROUP BY direction
`, [userId, today]);
const stats = {
likes: 0,
superlikes: 0,
passes: 0,
total: 0
};
result.rows.forEach((row: any) => {
const count = parseInt(row.count);
stats.total += count;
switch (row.direction) {
case 'like':
stats.likes = count;
break;
case 'super':
stats.superlikes = count;
break;
case 'pass':
stats.passes = count;
break;
}
});
return stats;
}
// Проверить лимиты свайпов
async checkSwipeLimits(userId: string): Promise<{
canLike: boolean;
canSuperLike: boolean;
likesLeft: number;
superLikesLeft: number;
}> {
const stats = await this.getDailySwipeStats(userId);
const likesPerDay = 100; // Из конфига
const superLikesPerDay = 1; // Из конфига
return {
canLike: stats.likes < likesPerDay,
canSuperLike: stats.superlikes < superLikesPerDay,
likesLeft: Math.max(0, likesPerDay - stats.likes),
superLikesLeft: Math.max(0, superLikesPerDay - stats.superlikes)
};
}
// Получить рекомендации для пользователя
async getRecommendations(userId: string, limit: number = 10): Promise<string[]> {
return this.profileService.findCompatibleProfiles(userId, limit)
.then(profiles => profiles.map(p => p.userId));
}
// Преобразование entity в модель Swipe
private mapEntityToSwipe(entity: any): Swipe {
return new Swipe({
id: entity.id,
userId: entity.swiper_id,
targetUserId: entity.swiped_id,
type: this.convertDirectionToSwipeType(entity.direction),
timestamp: entity.created_at,
isMatch: entity.is_match
});
}
// Преобразование entity в модель Match
private mapEntityToMatch(entity: any): Match {
return new Match({
id: entity.id,
userId1: entity.user1_id,
userId2: entity.user2_id,
createdAt: entity.matched_at || entity.created_at,
lastMessageAt: entity.last_message_at,
isActive: entity.status === 'active',
isSuperMatch: false, // Определяется из swipes если нужно
unreadCount1: 0,
unreadCount2: 0
});
}
// Получить взаимные лайки (потенциальные матчи)
async getMutualLikes(userId: string): Promise<string[]> {
const result = await query(`
SELECT DISTINCT s1.target_user_id
FROM swipes s1
JOIN swipes s2 ON s1.user_id = s2.target_user_id AND s1.target_user_id = s2.user_id
WHERE s1.user_id = $1
AND s1.type IN ('like', 'superlike')
AND s2.type IN ('like', 'superlike')
AND NOT EXISTS (
SELECT 1 FROM matches m
WHERE (m.user_id_1 = s1.user_id AND m.user_id_2 = s1.target_user_id)
OR (m.user_id_1 = s1.target_user_id AND m.user_id_2 = s1.user_id)
)
`, [userId]);
return result.rows.map((row: any) => row.target_user_id);
}
// Получить следующего кандидата для просмотра
async getNextCandidate(telegramId: string): Promise<Profile | null> {
// Сначала получаем профиль пользователя по telegramId
const userProfile = await this.profileService.getProfileByTelegramId(telegramId);
if (!userProfile) {
throw new BotError('User profile not found', 'PROFILE_NOT_FOUND');
}
// Получаем UUID пользователя
const userId = userProfile.userId;
// Получаем список уже просмотренных пользователей
const viewedUsers = await query(`
SELECT DISTINCT swiped_id
FROM swipes
WHERE swiper_id = $1
`, [userId]);
const viewedUserIds = viewedUsers.rows.map((row: any) => row.swiped_id);
viewedUserIds.push(userId); // Исключаем самого себя
// Формируем условие для исключения уже просмотренных
const excludeCondition = viewedUserIds.length > 0
? `AND p.user_id NOT IN (${viewedUserIds.map((_: any, i: number) => `$${i + 2}`).join(', ')})`
: '';
// Ищем подходящих кандидатов
const candidateQuery = `
SELECT p.*, u.telegram_id, u.username, u.first_name, u.last_name
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.is_visible = true
AND p.gender = $1
AND p.age BETWEEN ${userProfile.searchPreferences.minAge} AND ${userProfile.searchPreferences.maxAge}
${excludeCondition}
ORDER BY RANDOM()
LIMIT 1
`;
const params = [userProfile.interestedIn, ...viewedUserIds];
const result = await query(candidateQuery, params);
if (result.rows.length === 0) {
return null;
}
const candidateData = result.rows[0];
// Используем ProfileService для правильного маппинга данных
return this.profileService.mapEntityToProfile(candidateData);
}
}

View File

@@ -0,0 +1,334 @@
import TelegramBot from 'node-telegram-bot-api';
import { query } from '../database/connection';
import { ProfileService } from './profileService';
import config from '../../config/default.json';
export interface NotificationData {
userId: string;
type: 'new_match' | 'new_message' | 'new_like' | 'super_like';
data: Record<string, any>;
scheduledAt?: Date;
}
export class NotificationService {
private bot?: TelegramBot;
private profileService: ProfileService;
constructor(bot?: TelegramBot) {
this.bot = bot;
this.profileService = new ProfileService();
}
// Отправить уведомление о новом лайке
async sendLikeNotification(targetTelegramId: string, likerTelegramId: string, isSuperLike: boolean = false): Promise<void> {
try {
const [targetUser, likerProfile] = await Promise.all([
this.getUserByTelegramId(targetTelegramId),
this.profileService.getProfileByTelegramId(likerTelegramId)
]);
if (!targetUser || !likerProfile || !this.bot) {
return;
}
const message = isSuperLike
? `${likerProfile.name} отправил вам суперлайк!`
: `💖 ${likerProfile.name} поставил вам лайк!`;
await this.bot.sendMessage(targetUser.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '👀 Посмотреть профиль', callback_data: `view_profile:${likerProfile.userId}` },
{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' }
]]
}
});
// Логируем уведомление
await this.logNotification({
userId: targetUser.id,
type: isSuperLike ? 'super_like' : 'new_like',
data: { likerUserId: likerProfile.userId, likerName: likerProfile.name }
});
} catch (error) {
console.error('Error sending like notification:', error);
}
}
// Отправить уведомление о новом матче
async sendMatchNotification(userId: string, matchedUserId: string): Promise<void> {
try {
const [user, matchedProfile] = await Promise.all([
this.getUserByUserId(userId),
this.profileService.getProfileByUserId(matchedUserId)
]);
if (!user || !matchedProfile || !this.bot) {
return;
}
const message = `🎉 У вас новый матч с ${matchedProfile.name}!\n\nТеперь вы можете начать общение.`;
await this.bot.sendMessage(user.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '💬 Написать сообщение', callback_data: `start_chat:${matchedUserId}` },
{ text: '👀 Посмотреть профиль', callback_data: `view_profile:${matchedUserId}` }
]]
}
});
// Логируем уведомление
await this.logNotification({
userId,
type: 'new_match',
data: { matchedUserId, matchedName: matchedProfile.name }
});
} catch (error) {
console.error('Error sending match notification:', error);
}
}
// Отправить уведомление о новом сообщении
async sendMessageNotification(receiverId: string, senderId: string, messageContent: string): Promise<void> {
try {
const [receiver, senderProfile] = await Promise.all([
this.getUserByUserId(receiverId),
this.profileService.getProfileByUserId(senderId)
]);
if (!receiver || !senderProfile || !this.bot) {
return;
}
// Проверяем, не в чате ли пользователь сейчас
const isUserActive = await this.isUserActiveInChat(receiverId, senderId);
if (isUserActive) {
return; // Не отправляем уведомление, если пользователь активен в чате
}
const truncatedMessage = messageContent.length > 50
? messageContent.substring(0, 50) + '...'
: messageContent;
const message = `💬 Новое сообщение от ${senderProfile.name}:\n\n${truncatedMessage}`;
await this.bot.sendMessage(receiver.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '💬 Ответить', callback_data: `open_chat:${senderId}` }
]]
}
});
// Логируем уведомление
await this.logNotification({
userId: receiverId,
type: 'new_message',
data: { senderId, senderName: senderProfile.name, messageContent: truncatedMessage }
});
} catch (error) {
console.error('Error sending message notification:', error);
}
}
// Отправить напоминание о неактивности
async sendInactivityReminder(userId: string): Promise<void> {
try {
const user = await this.getUserByUserId(userId);
if (!user || !this.bot) {
return;
}
const message = `👋 Давно не виделись!\n\nВозможно, ваш идеальный матч уже ждет. Давайте найдем кого-то особенного?`;
await this.bot.sendMessage(user.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' },
{ text: '⚙️ Настройки', callback_data: 'settings' }
]]
}
});
} catch (error) {
console.error('Error sending inactivity reminder:', error);
}
}
// Отправить уведомление о новых лайках (сводка)
async sendLikesSummary(userId: string, likesCount: number): Promise<void> {
try {
const user = await this.getUserByUserId(userId);
if (!user || !this.bot || likesCount === 0) {
return;
}
const message = likesCount === 1
? `💖 У вас 1 новый лайк! Посмотрите, кто это может быть.`
: `💖 У вас ${likesCount} новых лайков! Посмотрите, кто проявил к вам интерес.`;
await this.bot.sendMessage(user.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '👀 Посмотреть лайки', callback_data: 'view_likes' },
{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' }
]]
}
});
} catch (error) {
console.error('Error sending likes summary:', error);
}
}
// Логирование уведомлений
private async logNotification(notificationData: NotificationData): Promise<void> {
try {
await query(`
INSERT INTO notifications (user_id, type, data, created_at)
VALUES ($1, $2, $3, $4)
`, [
notificationData.userId,
notificationData.type,
JSON.stringify(notificationData.data),
new Date()
]);
} catch (error) {
console.error('Error logging notification:', error);
}
}
// Получить пользователя по ID
private async getUserByUserId(userId: string): Promise<any> {
try {
const result = await query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
return result.rows[0] || null;
} catch (error) {
console.error('Error getting user:', error);
return null;
}
}
// Получить пользователя по Telegram ID
private async getUserByTelegramId(telegramId: string): Promise<any> {
try {
const result = await query(
'SELECT * FROM users WHERE telegram_id = $1',
[parseInt(telegramId)]
);
return result.rows[0] || null;
} catch (error) {
console.error('Error getting user by telegram ID:', error);
return null;
}
}
// Проверить, активен ли пользователь в чате
private async isUserActiveInChat(userId: string, chatWithUserId: string): Promise<boolean> {
// TODO: Реализовать проверку активности пользователя
// Можно использовать Redis для хранения состояния активности
return false;
}
// Отправить пуш-уведомление (для будущего использования)
async sendPushNotification(userId: string, title: string, body: string, data?: any): Promise<void> {
// TODO: Интеграция с Firebase Cloud Messaging или другим сервисом пуш-уведомлений
console.log(`Push notification for ${userId}: ${title} - ${body}`);
}
// Получить настройки уведомлений пользователя
async getNotificationSettings(userId: string): Promise<{
newMatches: boolean;
newMessages: boolean;
newLikes: boolean;
reminders: boolean;
}> {
try {
const result = await query(
'SELECT notification_settings FROM users WHERE id = $1',
[userId]
);
if (result.rows.length === 0) {
return {
newMatches: true,
newMessages: true,
newLikes: true,
reminders: true
};
}
return result.rows[0].notification_settings || {
newMatches: true,
newMessages: true,
newLikes: true,
reminders: true
};
} catch (error) {
console.error('Error getting notification settings:', error);
return {
newMatches: true,
newMessages: true,
newLikes: true,
reminders: true
};
}
}
// Обновить настройки уведомлений
async updateNotificationSettings(userId: string, settings: {
newMatches?: boolean;
newMessages?: boolean;
newLikes?: boolean;
reminders?: boolean;
}): Promise<void> {
try {
await query(
'UPDATE users SET notification_settings = $1 WHERE id = $2',
[JSON.stringify(settings), userId]
);
} catch (error) {
console.error('Error updating notification settings:', error);
}
}
// Планировщик уведомлений (вызывается периодически)
async processScheduledNotifications(): Promise<void> {
try {
// Получаем запланированные уведомления
const result = await query(`
SELECT * FROM scheduled_notifications
WHERE scheduled_at <= $1 AND sent = false
ORDER BY scheduled_at ASC
LIMIT 100
`, [new Date()]);
for (const notification of result.rows) {
try {
switch (notification.type) {
case 'inactivity_reminder':
await this.sendInactivityReminder(notification.user_id);
break;
case 'likes_summary':
const likesCount = notification.data?.likesCount || 0;
await this.sendLikesSummary(notification.user_id, likesCount);
break;
// Добавить другие типы уведомлений
}
// Отмечаем как отправленное
await query(
'UPDATE scheduled_notifications SET sent = true, sent_at = $1 WHERE id = $2',
[new Date(), notification.id]
);
} catch (error) {
console.error(`Error processing notification ${notification.id}:`, error);
}
}
} catch (error) {
console.error('Error processing scheduled notifications:', error);
}
}
}

View File

@@ -0,0 +1,470 @@
import { v4 as uuidv4 } from 'uuid';
import { query, transaction } from '../database/connection';
import { Profile, ProfileData } from '../models/Profile';
import { User } from '../models/User';
import {
ProfileEntity,
UserEntity,
ValidationResult,
BotError
} from '../types';
export class ProfileService {
// Создание нового профиля
async createProfile(userId: string, profileData: Partial<ProfileData>): Promise<Profile> {
const validation = this.validateProfileData(profileData);
if (!validation.isValid) {
throw new BotError(validation.errors.join(', '), 'VALIDATION_ERROR');
}
const profileId = uuidv4();
const now = new Date();
const profile = new Profile({
userId,
name: profileData.name!,
age: profileData.age!,
gender: profileData.gender!,
interestedIn: profileData.interestedIn!,
bio: profileData.bio,
photos: profileData.photos || [],
interests: profileData.interests || [],
city: profileData.city,
education: profileData.education,
job: profileData.job,
height: profileData.height,
location: profileData.location,
searchPreferences: profileData.searchPreferences || {
minAge: 18,
maxAge: 50,
maxDistance: 50
},
isVerified: false,
isVisible: true,
createdAt: now,
updatedAt: now
});
// Сохранение в базу данных
await query(`
INSERT INTO profiles (
id, user_id, name, age, gender, looking_for, bio, photos, interests,
location, education, occupation, height, latitude, longitude,
verification_status, is_active, is_visible, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
`, [
profileId, userId, profile.name, profile.age, profile.gender, profile.interestedIn,
profile.bio, profile.photos, profile.interests,
profile.city, profile.education, profile.job, profile.height,
profile.location?.latitude, profile.location?.longitude,
'unverified', true, profile.isVisible, profile.createdAt, profile.updatedAt
]);
return profile;
}
// Получение профиля по ID пользователя
async getProfileByUserId(userId: string): Promise<Profile | null> {
const result = await query(
'SELECT * FROM profiles WHERE user_id = $1',
[userId]
);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToProfile(result.rows[0]);
}
// Получение профиля по Telegram ID
async getProfileByTelegramId(telegramId: string): Promise<Profile | null> {
const result = await query(`
SELECT p.*, u.telegram_id, u.username, u.first_name, u.last_name
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE u.telegram_id = $1
`, [parseInt(telegramId)]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToProfile(result.rows[0]);
} // Получение UUID пользователя по Telegram ID
async getUserIdByTelegramId(telegramId: string): Promise<string | null> {
const result = await query(`
SELECT id FROM users WHERE telegram_id = $1
`, [parseInt(telegramId)]);
if (result.rows.length === 0) {
return null;
}
return result.rows[0].id;
}
// Создание пользователя если не существует
async ensureUser(telegramId: string, userData: any): Promise<string> {
// Используем UPSERT для избежания дублирования
const result = await query(`
INSERT INTO users (telegram_id, username, first_name, last_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (telegram_id) DO UPDATE SET
username = EXCLUDED.username,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
updated_at = CURRENT_TIMESTAMP
RETURNING id
`, [
parseInt(telegramId),
userData.username || null,
userData.first_name || null,
userData.last_name || null
]);
return result.rows[0].id;
}
// Обновление профиля
async updateProfile(userId: string, updates: Partial<ProfileData>): Promise<Profile> {
const existingProfile = await this.getProfileByUserId(userId);
if (!existingProfile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404);
}
const validation = this.validateProfileData(updates, false);
if (!validation.isValid) {
throw new BotError(validation.errors.join(', '), 'VALIDATION_ERROR');
}
const updateFields: string[] = [];
const updateValues: any[] = [];
let paramIndex = 1;
// Строим динамический запрос обновления
Object.entries(updates).forEach(([key, value]) => {
if (value !== undefined) {
switch (key) {
case 'photos':
case 'interests':
updateFields.push(`${this.camelToSnake(key)} = $${paramIndex++}`);
updateValues.push(JSON.stringify(value));
break;
case 'location':
if (value && typeof value === 'object' && 'latitude' in value) {
updateFields.push(`latitude = $${paramIndex++}`);
updateValues.push(value.latitude);
updateFields.push(`longitude = $${paramIndex++}`);
updateValues.push(value.longitude);
}
break;
case 'searchPreferences':
// Поля search preferences больше не хранятся в БД, пропускаем
break;
default:
updateFields.push(`${this.camelToSnake(key)} = $${paramIndex++}`);
updateValues.push(value);
}
}
});
if (updateFields.length === 0) {
return existingProfile;
}
updateFields.push(`updated_at = $${paramIndex++}`);
updateValues.push(new Date());
updateValues.push(userId);
const updateQuery = `
UPDATE profiles
SET ${updateFields.join(', ')}
WHERE user_id = $${paramIndex}
RETURNING *
`;
const result = await query(updateQuery, updateValues);
return this.mapEntityToProfile(result.rows[0]);
}
// Добавление фото к профилю
async addPhoto(userId: string, photoFileId: string): Promise<Profile> {
const profile = await this.getProfileByUserId(userId);
if (!profile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404);
}
profile.addPhoto(photoFileId);
await query(
'UPDATE profiles SET photos = $1, updated_at = $2 WHERE user_id = $3',
[JSON.stringify(profile.photos), new Date(), userId]
);
return profile;
}
// Удаление фото из профиля
async removePhoto(userId: string, photoId: string): Promise<Profile> {
const profile = await this.getProfileByUserId(userId);
if (!profile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404);
}
profile.removePhoto(photoId);
await query(
'UPDATE profiles SET photos = $1, updated_at = $2 WHERE user_id = $3',
[JSON.stringify(profile.photos), new Date(), userId]
);
return profile;
}
// Поиск совместимых профилей
async findCompatibleProfiles(
userId: string,
limit: number = 10,
excludeUserIds: string[] = []
): Promise<Profile[]> {
const userProfile = await this.getProfileByUserId(userId);
if (!userProfile) {
throw new BotError('User profile not found', 'PROFILE_NOT_FOUND', 404);
}
// Получаем ID пользователей, которых уже свайпали
const swipedUsersResult = await query(
'SELECT target_user_id FROM swipes WHERE user_id = $1',
[userId]
);
const swipedUserIds = swipedUsersResult.rows.map((row: any) => row.target_user_id);
const allExcludedIds = [...excludeUserIds, ...swipedUserIds, userId];
// Базовый запрос для поиска совместимых профилей
let searchQuery = `
SELECT p.*, u.id as user_id
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.is_visible = true
AND u.is_active = true
AND p.user_id != $1
AND p.age BETWEEN $2 AND $3
AND p.gender = $4
AND p.interested_in IN ($5, 'both')
AND $6 BETWEEN p.search_min_age AND p.search_max_age
`;
const queryParams: any[] = [
userId,
userProfile.searchPreferences.minAge,
userProfile.searchPreferences.maxAge,
userProfile.interestedIn === 'both' ? userProfile.gender : userProfile.interestedIn,
userProfile.gender,
userProfile.age
];
// Исключаем уже просмотренных пользователей
if (allExcludedIds.length > 0) {
const placeholders = allExcludedIds.map((_, index) => `$${queryParams.length + index + 1}`).join(',');
searchQuery += ` AND p.user_id NOT IN (${placeholders})`;
queryParams.push(...allExcludedIds);
}
// Добавляем фильтр по расстоянию, если есть координаты
if (userProfile.location) {
searchQuery += `
AND (
p.location_lat IS NULL OR
p.location_lon IS NULL OR
(
6371 * acos(
cos(radians($${queryParams.length + 1})) *
cos(radians(p.location_lat)) *
cos(radians(p.location_lon) - radians($${queryParams.length + 2})) +
sin(radians($${queryParams.length + 1})) *
sin(radians(p.location_lat))
)
) <= $${queryParams.length + 3}
)
`;
queryParams.push(
userProfile.location.latitude,
userProfile.location.longitude,
userProfile.searchPreferences.maxDistance
);
}
searchQuery += ` ORDER BY RANDOM() LIMIT $${queryParams.length + 1}`;
queryParams.push(limit);
const result = await query(searchQuery, queryParams);
return result.rows.map((row: any) => this.mapEntityToProfile(row));
}
// Получение статистики профиля
async getProfileStats(userId: string): Promise<{
totalLikes: number;
totalMatches: number;
profileViews: number;
likesReceived: number;
}> {
const [likesResult, matchesResult, likesReceivedResult] = await Promise.all([
query('SELECT COUNT(*) as count FROM swipes WHERE swiper_id = $1 AND direction IN ($2, $3)',
[userId, 'like', 'super']),
query('SELECT COUNT(*) as count FROM matches WHERE (user1_id = $1 OR user2_id = $1) AND status = $2',
[userId, 'active']),
query('SELECT COUNT(*) as count FROM swipes WHERE swiped_id = $1 AND direction IN ($2, $3)',
[userId, 'like', 'super'])
]);
return {
totalLikes: parseInt(likesResult.rows[0].count),
totalMatches: parseInt(matchesResult.rows[0].count),
profileViews: 0, // TODO: implement profile views tracking
likesReceived: parseInt(likesReceivedResult.rows[0].count)
};
}
// Валидация данных профиля
private validateProfileData(data: Partial<ProfileData>, isRequired = true): ValidationResult {
const errors: string[] = [];
if (isRequired || data.name !== undefined) {
if (!data.name || data.name.trim().length === 0) {
errors.push('Name is required');
} else if (data.name.length > 50) {
errors.push('Name must be less than 50 characters');
}
}
if (isRequired || data.age !== undefined) {
if (!data.age || data.age < 18 || data.age > 100) {
errors.push('Age must be between 18 and 100');
}
}
if (isRequired || data.gender !== undefined) {
if (!data.gender || !['male', 'female', 'other'].includes(data.gender)) {
errors.push('Gender must be male, female, or other');
}
}
if (isRequired || data.interestedIn !== undefined) {
if (!data.interestedIn || !['male', 'female', 'both'].includes(data.interestedIn)) {
errors.push('Interested in must be male, female, or both');
}
}
if (data.bio && data.bio.length > 500) {
errors.push('Bio must be less than 500 characters');
}
if (data.photos && data.photos.length > 6) {
errors.push('Maximum 6 photos allowed');
}
if (data.interests && data.interests.length > 10) {
errors.push('Maximum 10 interests allowed');
}
if (data.height && (data.height < 100 || data.height > 250)) {
errors.push('Height must be between 100 and 250 cm');
}
return {
isValid: errors.length === 0,
errors
};
}
// Преобразование entity в модель Profile
public mapEntityToProfile(entity: any): Profile {
// Функция для парсинга PostgreSQL массивов
const parsePostgresArray = (pgArray: string | null): string[] => {
if (!pgArray) return [];
// PostgreSQL возвращает массивы в формате {item1,item2,item3}
if (typeof pgArray === 'string' && pgArray.startsWith('{') && pgArray.endsWith('}')) {
const content = pgArray.slice(1, -1); // Убираем фигурные скобки
if (content === '') return [];
return content.split(',').map(item => item.trim());
}
// Если это уже массив, возвращаем как есть
if (Array.isArray(pgArray)) return pgArray;
return [];
};
return new Profile({
userId: entity.user_id,
name: entity.name,
age: entity.age,
gender: entity.gender,
interestedIn: entity.looking_for,
bio: entity.bio,
photos: parsePostgresArray(entity.photos),
interests: parsePostgresArray(entity.interests),
city: entity.location || entity.city,
education: entity.education,
job: entity.occupation || entity.job,
height: entity.height,
location: entity.latitude && entity.longitude ? {
latitude: entity.latitude,
longitude: entity.longitude
} : undefined,
searchPreferences: {
minAge: 18,
maxAge: 50,
maxDistance: 50
},
isVerified: entity.verification_status === 'verified',
isVisible: entity.is_visible,
createdAt: entity.created_at,
updatedAt: entity.updated_at
});
}
// Преобразование camelCase в snake_case
private camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
}
// Удаление профиля
async deleteProfile(userId: string): Promise<boolean> {
try {
await transaction(async (client) => {
// Удаляем связанные данные
await client.query('DELETE FROM messages WHERE sender_id = $1 OR receiver_id = $1', [userId]);
await client.query('DELETE FROM matches WHERE user1_id = $1 OR user2_id = $1', [userId]);
await client.query('DELETE FROM swipes WHERE swiper_id = $1 OR swiped_id = $1', [userId]);
await client.query('DELETE FROM profiles WHERE user_id = $1', [userId]);
});
return true;
} catch (error) {
console.error('Error deleting profile:', error);
return false;
}
}
// Скрыть/показать профиль
async toggleVisibility(userId: string): Promise<Profile> {
const profile = await this.getProfileByUserId(userId);
if (!profile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404);
}
const newVisibility = !profile.isVisible;
await query(
'UPDATE profiles SET is_visible = $1, updated_at = $2 WHERE user_id = $3',
[newVisibility, new Date(), userId]
);
profile.isVisible = newVisibility;
return profile;
}
}

211
src/types/index.ts Normal file
View File

@@ -0,0 +1,211 @@
// Bot State Types
export type BotState =
| 'start'
| 'registration'
| 'profile_setup'
| 'browsing'
| 'matches'
| 'chat'
| 'settings';
export type RegistrationStep =
| 'name'
| 'age'
| 'gender'
| 'interested_in'
| 'photos'
| 'bio'
| 'location'
| 'complete';
// Telegram Types
export interface TelegramUser {
id: number;
is_bot: boolean;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
}
export interface TelegramMessage {
message_id: number;
from?: TelegramUser;
chat: {
id: number;
type: string;
};
date: number;
text?: string;
photo?: Array<{
file_id: string;
file_unique_id: string;
width: number;
height: number;
file_size?: number;
}>;
location?: {
longitude: number;
latitude: number;
};
}
// User Session Types
export interface UserSession {
userId: string;
telegramId: number;
state: BotState;
registrationStep?: RegistrationStep;
currentProfileId?: string;
tempData?: Record<string, any>;
lastActivity: Date;
}
// Database Entity Types
export interface UserEntity {
id: string;
telegram_id: number;
username?: string;
first_name?: string;
last_name?: string;
language_code?: string;
is_active: boolean;
created_at: Date;
last_active_at: Date;
}
export interface ProfileEntity {
id: string;
user_id: string;
name: string;
age: number;
gender: 'male' | 'female' | 'other';
interested_in: 'male' | 'female' | 'both';
bio?: string;
photos: string; // JSON array
interests: string; // JSON array
city?: string;
education?: string;
job?: string;
height?: number;
location_lat?: number;
location_lon?: number;
search_min_age: number;
search_max_age: number;
search_max_distance: number;
is_verified: boolean;
is_visible: boolean;
created_at: Date;
updated_at: Date;
}
export interface SwipeEntity {
id: string;
user_id: string;
target_user_id: string;
type: 'like' | 'pass' | 'superlike';
created_at: Date;
is_match: boolean;
}
export interface MatchEntity {
id: string;
user_id_1: string;
user_id_2: string;
created_at: Date;
last_message_at?: Date;
is_active: boolean;
is_super_match: boolean;
unread_count_1: number;
unread_count_2: number;
}
export interface MessageEntity {
id: string;
match_id: string;
sender_id: string;
receiver_id: string;
content: string;
message_type: 'text' | 'photo' | 'gif' | 'sticker';
created_at: Date;
is_read: boolean;
}
// API Response Types
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
hasNext: boolean;
hasPrev: boolean;
}
// Service Types
export interface MatchingOptions {
maxDistance?: number;
minAge?: number;
maxAge?: number;
excludeUserIds?: string[];
limit?: number;
}
export interface NotificationData {
userId: string;
type: 'new_match' | 'new_message' | 'new_like' | 'super_like';
data: Record<string, any>;
scheduledAt?: Date;
}
// Validation Types
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
// Error Types
export class BotError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 400
) {
super(message);
this.name = 'BotError';
}
}
// Configuration Types
export interface BotConfig {
telegram: {
token: string;
webhookUrl?: string;
};
database: {
host: string;
port: number;
name: string;
username: string;
password: string;
};
redis?: {
host: string;
port: number;
password?: string;
};
app: {
maxPhotos: number;
maxDistance: number;
minAge: number;
maxAge: number;
superLikesPerDay: number;
likesPerDay: number;
};
}

20
src/utils/helpers.ts Normal file
View File

@@ -0,0 +1,20 @@
export function generateRandomId(): string {
return Math.random().toString(36).substr(2, 9);
}
export function formatUserProfile(profile: any): string {
return `Имя: ${profile.name}\nВозраст: ${profile.age}\nИнтересы: ${profile.interests.join(', ')}`;
}
export function isValidUsername(username: string): boolean {
const regex = /^[a-zA-Z0-9_]{3,15}$/;
return regex.test(username);
}
export function isValidAge(age: number): boolean {
return age >= 18 && age <= 100;
}
export function getSwipeDirectionEmoji(direction: 'left' | 'right'): string {
return direction === 'left' ? '👈' : '👉';
}

44
src/utils/validation.ts Normal file
View File

@@ -0,0 +1,44 @@
import { Profile } from '../models/Profile';
import { Swipe } from '../models/Swipe';
export function validateProfile(profile: any) {
const { userId, age, gender, interests } = profile;
if (!userId || typeof userId !== 'string') {
return { valid: false, message: 'Invalid userId' };
}
if (!age || typeof age !== 'number' || age < 18 || age > 100) {
return { valid: false, message: 'Age must be a number between 18 and 100' };
}
const validGenders = ['male', 'female', 'other'];
if (!gender || !validGenders.includes(gender)) {
return { valid: false, message: 'Gender must be one of: male, female, other' };
}
if (!Array.isArray(interests) || interests.length === 0) {
return { valid: false, message: 'Interests must be a non-empty array' };
}
return { valid: true, message: 'Profile is valid' };
}
export function validateSwipe(swipe: any) {
const { userId, targetUserId, direction } = swipe;
if (!userId || typeof userId !== 'string') {
return { valid: false, message: 'Invalid userId' };
}
if (!targetUserId || typeof targetUserId !== 'string') {
return { valid: false, message: 'Invalid targetUserId' };
}
const validDirections = ['left', 'right'];
if (!direction || !validDirections.includes(direction)) {
return { valid: false, message: 'Direction must be either left or right' };
}
return { valid: true, message: 'Swipe is valid' };
}