============================================================ Project Files Export Generated: Sun 08/16/2026 11:19:07.49 Root: D:\myProjects\vip-telegram-platform\apps\api\src ============================================================ schema.prisma: datasource db { provider = "postgresql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } enum UserRole { USER ADMIN } enum BanStatus { NONE TEMPORARY PERMANENT } model User { id String @id @default(cuid()) telegramId String @unique username String? firstName String? lastName String? channelBans ChannelMemberBan[] // 🆕 owner Owner? subscriptions Subscription[] paymentRequestsAsPayer PaymentRequest[] @relation("PaymentRequestsAsPayer") paymentRequestsAsAdmin PaymentRequest[] @relation("AdminAsReceiver") adminBankCards BankCard[] @relation("AdminBankCards") transactions Transaction[] role UserRole @default(USER) session TelegramSession? banStatus BanStatus @default(NONE) banReason String? bannedUntil DateTime? // برای بن موقت؛ null یعنی دائم یا اصلاً بن نیست createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([username]) // 🆕 برای جستجوی سریع‌تر @@index([firstName]) // 🆕 } enum TransactionType { BOT_SUBSCRIPTION CHANNEL_SUBSCRIPTION REFUND MANUAL_ADJUSTMENT } model Transaction { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id]) paymentRequestId String @unique paymentRequest PaymentRequest @relation(fields: [paymentRequestId], references: [id]) amount Int type TransactionType createdAt DateTime @default(now()) @@index([userId]) } // 🆕 کارت بانکی — هم Admin هم Owner می‌تونن چند کارت داشته باشن model BankCard { id String @id @default(cuid()) cardNumber String holderName String isDefault Boolean @default(false) // یکی از این دو باید پر باشه (کارت متعلق به Admin یا Owner است) ownerId String? owner Owner? @relation(fields: [ownerId], references: [id]) adminUserId String? // برای Admin از userId خودش استفاده می‌کنیم (Owner جداگانه نداره) adminUser User? @relation("AdminBankCards", fields: [adminUserId], references: [id]) isActive Boolean @default(true) // حذف نرم createdAt DateTime @default(now()) paymentRequests PaymentRequest[] @@index([ownerId]) @@index([adminUserId]) } enum PaymentRequestStatus { WaitingReceipt // منتظر آپلود فیش توسط پرداخت‌کننده WaitingApproval // فیش ارسال شده، منتظر تایید دریافت‌کننده Approved Rejected Expired Canceled } enum PaymentRequestType { CHANNEL_SUBSCRIPTION BOT_SUBSCRIPTION } // 🆕 قلب معماری جدید — جایگزین همه‌ی چیزی که قبلاً Transaction (PENDING) انجام می‌داد model PaymentRequest { id String @id @default(cuid()) paymentCode String @unique // 🔑 شناسه یکتا مثل CH-928461 type PaymentRequestType amount Int // مبلغ مورد انتظار // پرداخت‌کننده (همیشه User هست — چه برای خرید کانال، چه برای خرید ربات توسط owner) payerId String payer User @relation("PaymentRequestsAsPayer", fields: [payerId], references: [id]) // دریافت‌کننده: یا یک Owner (برای CHANNEL_SUBSCRIPTION) یا یک Admin User (برای BOT_SUBSCRIPTION) receiverOwnerId String? receiverOwner Owner? @relation(fields: [receiverOwnerId], references: [id]) receiverAdminUserId String? receiverAdminUser User? @relation("AdminAsReceiver", fields: [receiverAdminUserId], references: [id]) bankCardId String bankCard BankCard @relation(fields: [bankCardId], references: [id]) planId String? plan Plan? @relation(fields: [planId], references: [id]) botPlanId String? botPlan BotPlan? @relation(fields: [botPlanId], references: [id]) // برای تمدید/ارتقا — دقیقاً همون منطق قبلی Transaction subscriptionId String? subscription Subscription? @relation(fields: [subscriptionId], references: [id]) renewOrUpgrade String? // 'renew' | 'upgrade' | null discountCodeId String? // 🆕 discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id]) receiptFileId String? // 🔑 file_id تلگرام، نه فایل واقعی rejectReason String? status PaymentRequestStatus @default(WaitingReceipt) expiresAt DateTime // برای چک انقضای 24 ساعته transaction Transaction? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([payerId]) @@index([receiverOwnerId]) @@index([status, expiresAt]) // برای Cron انقضا } model TelegramSession { id String @id @default(cuid()) userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) step TelegramSessionStep @default(IDLE) data Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model BotPlan { id String @id @default(cuid()) title String price Int durationUnit DurationUnit durationValue Int isActive Boolean @default(true) createdAt DateTime @default(now()) // 🆕 طرف دیگه‌ی رابطه‌ها — بدون این‌ها validate fail می‌کند botSubscriptions BotSubscription[] paymentRequests PaymentRequest[] discountCodes DiscountCode[] } model BotSubscription { id String @id @default(cuid()) ownerId String owner Owner @relation(fields: [ownerId], references: [id]) botPlanId String botPlan BotPlan @relation(fields: [botPlanId], references: [id]) status SubscriptionStatus startedAt DateTime @default(now()) expiresAt DateTime reminderSentAt DateTime? // 🆕 برای یادآوری ۳ روز قبل از انقضا @@index([ownerId]) @@index([botPlanId]) @@index([ownerId, status, expiresAt]) } enum TelegramSessionStep { IDLE WAITING_CHANNEL_ADMIN_CONFIRM WAITING_PLAN_TITLE WAITING_PLAN_DURATION_UNIT WAITING_PLAN_DURATION_VALUE WAITING_PLAN_PRICE WAITING_PLAN_EDIT_VALUE WAITING_MEMBER_SEARCH_QUERY // 🆕 ساخت پلن اشتراک ربات (Admin) WAITING_BOT_PLAN_TITLE WAITING_BOT_PLAN_DURATION_UNIT WAITING_BOT_PLAN_DURATION_VALUE WAITING_BOT_PLAN_PRICE WAITING_PAYMENT_RECEIPT // 🆕 منتظر عکس فیش از پرداخت‌کننده WAITING_REJECT_REASON // 🆕 منتظر دلیل رد از دریافت‌کننده (Owner/Admin) // 🆕 ساخت کد تخفیف (Owner یا Admin) WAITING_NEW_DISCOUNT_CODE WAITING_NEW_DISCOUNT_VALUE WAITING_NEW_DISCOUNT_EXPIRY WAITING_NEW_DISCOUNT_MAX_USAGE WAITING_NEW_DISCOUNT_EXPIRY_DAYS WAITING_BAN_DURATION_DAYS // 🆕 چند روز بن (برای بن موقت) WAITING_BAN_REASON // 🆕 دلیل بن WAITING_ADMIN_USER_SEARCH_QUERY // 🆕 جستجوی سراسری کاربر توسط Admin WAITING_PLAN_CONFIRM WAITING_BOT_PLAN_CONFIRM WAITING_BROADCAST_CONTENT WAITING_ADMIN_DISCOUNT_CODE WAITING_ADMIN_DISCOUNT_VALUE WAITING_ADMIN_DISCOUNT_MAX_USAGE WAITING_ADMIN_DISCOUNT_EXPIRY_DAYS WAITING_BANK_CARD_NUMBER WAITING_BANK_CARD_HOLDER WAITING_REDEEM_DISCOUNT_CODE // 🆕 وارد کردن کد تخفیف توسط خریدار WAITING_OUTAGE_START_DATE // 🆕 WAITING_OUTAGE_END_DATE // 🆕 } model Channel { id String @id @default(cuid()) telegramChannelId String @unique title String username String? isActive Boolean @default(true) // 🆕 حذف نرم — قبلاً حذف فیزیکی بود ownerId String owner Owner @relation(fields: [ownerId], references: [id]) plans Plan[] subscriptions Subscription[] broadcasts Broadcast[] channelMemberBans ChannelMemberBan[] // 🆕 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([ownerId]) } enum DurationUnit { DAY MONTH YEAR } model Plan { id String @id @default(cuid()) title String price Decimal durationUnit DurationUnit durationValue Int // durationDays حذف شد — هیچ‌جا خونده نمی‌شد و با محاسبه‌ی واقعی انقضا (تقویمی) هم‌خوان نبود channelId String channel Channel @relation(fields: [channelId], references: [id]) subscriptions Subscription[] paymentRequests PaymentRequest[] discountCodes DiscountCode[] isActive Boolean @default(true) createdAt DateTime @default(now()) @@index([channelId]) } enum SubscriptionStatus { ACTIVE EXPIRED CANCELLED } model Subscription { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id]) channelId String channel Channel @relation(fields: [channelId], references: [id]) planId String plan Plan @relation(fields: [planId], references: [id]) status SubscriptionStatus @default(ACTIVE) paymentRequests PaymentRequest[] startedAt DateTime @default(now()) expiresAt DateTime renewedCount Int @default(0) reminderSentAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([userId]) @@index([channelId]) @@index([status, expiresAt]) // 🆕 برای findExpired @@index([status, reminderSentAt, expiresAt]) // 🆕 برای findExpiringSoon @@index([userId, channelId, status]) // 🆕 برای findActiveByUserAndChannel } enum DiscountType { PERCENTAGE FIXED } // 🆕 دامنه‌ی اعمال کد تخفیف — چون حالا هم پلن کانال و هم پلن اشتراک ربات داریم enum DiscountScope { CHANNEL_PLAN BOT_PLAN } model DiscountCode { id String @id @default(cuid()) code String @unique type DiscountType value Int maxUsage Int? usedCount Int @default(0) expiresAt DateTime? ownerId String? owner Owner? @relation(fields: [ownerId], references: [id]) scope DiscountScope // channelId حذف شد — هیچ‌جا استفاده نمی‌شد planId String? plan Plan? @relation(fields: [planId], references: [id]) botPlanId String? botPlan BotPlan? @relation(fields: [botPlanId], references: [id]) isActive Boolean @default(true) paymentRequests PaymentRequest[] // 🆕 createdAt DateTime @default(now()) @@index([ownerId]) } enum BroadcastStatus { SCHEDULED SENDING DONE FAILED } model Broadcast { id String @id @default(cuid()) ownerId String owner Owner @relation(fields: [ownerId], references: [id]) channelId String? channel Channel? @relation(fields: [channelId], references: [id]) content String scheduledAt DateTime? status BroadcastStatus @default(SCHEDULED) sentCount Int @default(0) createdAt DateTime @default(now()) @@index([ownerId]) @@index([status, scheduledAt]) // 🆕 برای findDue } model Owner { id String @id @default(cuid()) userId String @unique displayName String status OwnerStatus @default(ACTIVE) user User @relation(fields: [userId], references: [id]) bankCards BankCard[] paymentRequests PaymentRequest[] banStatus BanStatus @default(NONE) banReason String? bannedUntil DateTime? channels Channel[] discountCodes DiscountCode[] broadcasts Broadcast[] botSubscriptions BotSubscription[] // 🆕 طرف دیگه‌ی رابطه createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum OwnerStatus { ACTIVE INACTIVE SUSPENDED } // 🆕 بن کاربر توسط Owner — مختص یک کانال خاص model ChannelMemberBan { id String @id @default(cuid()) channelId String channel Channel @relation(fields: [channelId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) reason String? bannedUntil DateTime? // null یعنی دائمی isActive Boolean @default(true) // رفع بن → false createdAt DateTime @default(now()) @@unique([channelId, userId]) @@index([channelId]) @@index([userId]) } enum OutageStatus { PENDING_CONFIRMATION // تشخیص داده شده، منتظر تایید ادمین یا تایم‌اوت CONFIRMED // تایید شده (دستی یا خودکار بعد از ۲۴ ساعت) REJECTED // ادمین رد کرد (مثبت کاذب بوده) APPLIED // اصلاح اعتبار اشتراک‌ها اعمال شد FAILED // اعمال اصلاح با خطا مواجه شد } model ServiceOutage { id String @id @default(cuid()) detectedAt DateTime // لحظه‌ی تشخیص قطعی confirmationDeadline DateTime // detectedAt + 24 ساعت confirmedAt DateTime? confirmationSource String? // 'ADMIN' | 'AUTO_TIMEOUT' reconnectedAt DateTime? // لحظه‌ی وصل شدن مجدد status OutageStatus @default(PENDING_CONFIRMATION) appliedAt DateTime? notifiedAdminTelegramId String? // به کدوم ادمین پیام رفت createdAt DateTime @default(now()) @@index([status]) } ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\app.controller.spec.ts ############################################################ import { Test, TestingModule } from '@nestjs/testing'; import { AppController } from './app.controller'; import { AppService } from './app.service'; describe('AppController', () => { let appController: AppController; beforeEach(async () => { const app: TestingModule = await Test.createTestingModule({ controllers: [AppController], providers: [AppService], }).compile(); appController = app.get(AppController); }); describe('root', () => { it('should return "Hello World!"', () => { expect(appController.getHello()).toBe('Hello World!'); }); }); }); ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\app.controller.ts ############################################################ import { Controller, Get } from '@nestjs/common'; // import { AppService } from './app.service'; import { PrismaService } from './prisma/prisma.service'; @Controller() export class AppController { constructor(private readonly prisma: PrismaService) {} @Get() async health() { const result = await this.prisma.$queryRaw` SELECT NOW() `; return result; } } ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\app.module.ts ############################################################ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { PrismaModule } from './prisma/prisma.module'; import { ConfigModule } from '@nestjs/config/dist/config.module'; import { ScheduleModule } from '@nestjs/schedule'; import { TelegramModule } from './modules/telegram/telegram.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), PrismaModule, ScheduleModule.forRoot(), TelegramModule, ], controllers: [AppController], providers: [AppService], }) export class AppModule { } ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\app.service.ts ############################################################ import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { getHello(): string { return 'Hello World!'; } } ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\main.ts ############################################################ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(process.env.PORT ?? 3000); } bootstrap(); ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\common\utils\date.util.ts ############################################################ . export function addDays(date: Date, days: number): Date { const result = new Date(date); result.setDate(result.getDate() + days); return result; } export function addMonths(date: Date, months: number): Date { const result = new Date(date); result.setMonth(result.getMonth() + months); return result; } export function addYears(date: Date, years: number): Date { const result = new Date(date); result.setFullYear(result.getFullYear() + years); return result; } export function formatPersianDate(date: Date): string { return date.toLocaleDateString('fa-IR', { timeZone: 'Asia/Tehran' }); }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\common\utils\jalali-date.util.ts ############################################################ . import { toGregorian } from 'jalaali-js'; const JALALI_REGEX = /^(\d{4})\/(\d{1,2})\/(\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?$/; const TEHRAN_OFFSET_MS = 3.5 * 60 * 60 * 1000; // مطابق الگوی stats.service.ts // ورودی مثل "1403/05/12" یا "1403/05/12 14:30" — خروجی Date معادل UTC export function parseJalaliDateTime(input: string): Date | null { const match = input.trim().match(JALALI_REGEX); if (!match) return null; const [, jy, jm, jd, hh, mm] = match; const jyNum = Number(jy), jmNum = Number(jm), jdNum = Number(jd); if (jmNum < 1 || jmNum > 12 || jdNum < 1 || jdNum > 31) return null; const hour = hh ? Number(hh) : 0; const minute = mm ? Number(mm) : 0; if (hour > 23 || minute > 59) return null; try { const { gy, gm, gd } = toGregorian(jyNum, jmNum, jdNum); const tehranAsUtcMs = Date.UTC(gy, gm - 1, gd, hour, minute); return new Date(tehranAsUtcMs - TEHRAN_OFFSET_MS); } catch { return null; } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\common\utils\payment-code.util.ts ############################################################ . export function generatePaymentCodePrefix(type: 'CHANNEL_SUBSCRIPTION' | 'BOT_SUBSCRIPTION'): string { return type === 'CHANNEL_SUBSCRIPTION' ? 'CH' : 'BT'; } export function generateRandomCode(): string { return String(Math.floor(100000 + Math.random() * 900000)); // 6 رقم }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\bank-cards\bank-card.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { BankCardService } from './services/bank-card.service'; @Module({ providers: [BankCardService], exports: [BankCardService], }) export class BankCardModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\bank-cards\services\bank-card.service.ts ############################################################ . import { Injectable, BadRequestException } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; @Injectable() export class BankCardService { constructor(private readonly prisma: PrismaService) { } async getDefaultForOwner(ownerId: string) { return this.prisma.bankCard.findFirst({ where: { ownerId, isActive: true, isDefault: true }, }); } async getDefaultForAdmin(adminUserId: string) { return this.prisma.bankCard.findFirst({ where: { adminUserId, isActive: true, isDefault: true }, }); } async addForOwner(ownerId: string, cardNumber: string, holderName: string) { return this.prisma.$transaction(async (tx) => { const existingCount = await tx.bankCard.count({ where: { ownerId, isActive: true } }); const card = await tx.bankCard.create({ data: { ownerId, cardNumber, holderName, isDefault: existingCount === 0 }, // 🔑 اولین کارت خودکار پیش‌فرض }); return card; }); } async setDefaultForOwner(ownerId: string, cardId: string) { return this.prisma.$transaction(async (tx) => { await tx.bankCard.updateMany({ where: { ownerId }, data: { isDefault: false } }); // 🔑 اول همه رو false کن await tx.bankCard.update({ where: { id: cardId }, data: { isDefault: true } }); }); } async addForAdmin(adminUserId: string, cardNumber: string, holderName: string) { return this.prisma.$transaction(async (tx) => { const existingCount = await tx.bankCard.count({ where: { adminUserId, isActive: true } }); return tx.bankCard.create({ data: { adminUserId, cardNumber, holderName, isDefault: existingCount === 0 }, }); }); } async setDefaultForAdmin(adminUserId: string, cardId: string) { return this.prisma.$transaction(async (tx) => { await tx.bankCard.updateMany({ where: { adminUserId }, data: { isDefault: false } }); await tx.bankCard.update({ where: { id: cardId }, data: { isDefault: true } }); }); } async listForOwner(ownerId: string) { return this.prisma.bankCard.findMany({ where: { ownerId, isActive: true }, orderBy: { createdAt: 'desc' } }); } async listForAdmin(adminUserId: string) { return this.prisma.bankCard.findMany({ where: { adminUserId, isActive: true }, orderBy: { createdAt: 'desc' } }); } async deactivate(cardId: string): Promise { await this.prisma.bankCard.update({ where: { id: cardId }, data: { isActive: false } }); } async findAnyAdminWithDefaultCard() { return this.prisma.bankCard.findFirst({ where: { adminUserId: { not: null }, isActive: true, isDefault: true }, include: { adminUser: true }, }); } // متدهای مشابه addForAdmin / setDefaultForAdmin در مرحله بعد که UI کارت‌ها رو می‌سازیم اضافه می‌شن // bank-card.service.ts — اضافه کن async listForOwnerPaginated(ownerId: string, page = 0, pageSize = 10) { return this.prisma.bankCard.findMany({ where: { ownerId, isActive: true }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } async listForAdminPaginated(adminUserId: string, page = 0, pageSize = 10) { return this.prisma.bankCard.findMany({ where: { adminUserId, isActive: true }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } async countForOwner(ownerId: string): Promise { return this.prisma.bankCard.count({ where: { ownerId, isActive: true } }); } async countForAdmin(adminUserId: string): Promise { return this.prisma.bankCard.count({ where: { adminUserId, isActive: true } }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\bot-plans\bot-plan.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { BotPlanService } from './services/bot-plan.service'; import { BotPlanRepository } from './repository/bot-plan.repository'; @Module({ providers: [BotPlanService, BotPlanRepository], exports: [BotPlanService], }) export class BotPlanModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\bot-plans\repository\bot-plan.repository.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { DurationUnit } from '@prisma/client'; @Injectable() export class BotPlanRepository { constructor(private readonly prisma: PrismaService) { } create(data: { title: string; price: number; durationUnit: DurationUnit; durationValue: number; }) { return this.prisma.botPlan.create({ data: { ...data, isActive: true }, }); } findActive() { return this.prisma.botPlan.findMany({ where: { isActive: true }, }); } findById(id: string) { return this.prisma.botPlan.findUnique({ where: { id } }); } deactivate(id: string) { return this.prisma.botPlan.update({ where: { id }, data: { isActive: false }, }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\bot-plans\services\bot-plan.service.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { BotPlanRepository } from '../repository/bot-plan.repository'; import { DurationUnit } from '@prisma/client'; @Injectable() export class BotPlanService { constructor(private readonly repo: BotPlanRepository) { } create(data: { title: string; price: number; durationUnit: DurationUnit; durationValue: number; }) { return this.repo.create(data); } findActive() { return this.repo.findActive(); } findById(id: string) { return this.repo.findById(id); } deactivate(id: string) { return this.repo.deactivate(id); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\bot-subscriptions\bot-subscription.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { BotSubscriptionService } from './services/bot-subscription.service'; import { BotPlanModule } from 'src/modules/bot-plans/bot-plan.module'; import { TelegramCoreModule } from '../telegram/telegram_core.module'; import { BotSubscriptionExpiryJob } from './jobs/bot-subscription-expiry.job'; import { ServiceOutageModule } from '../service-outage/service-outage.module'; @Module({ imports: [BotPlanModule, TelegramCoreModule, ServiceOutageModule], providers: [BotSubscriptionService, BotSubscriptionExpiryJob], exports: [BotSubscriptionService], }) export class BotSubscriptionModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\bot-subscriptions\jobs\bot-subscription-expiry.job.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { InlineKeyboard } from 'grammy'; import { BotSubscriptionService } from '../services/bot-subscription.service'; import { TelegramApiService } from 'src/modules/telegram/services/telegram-api.service'; import { formatPersianDate } from 'src/common/utils/date.util'; import { Callback } from 'src/modules/telegram/constants/callback'; @Injectable() export class BotSubscriptionExpiryJob { private readonly logger = new Logger(BotSubscriptionExpiryJob.name); constructor( private readonly botSubscriptionService: BotSubscriptionService, private readonly telegramApiService: TelegramApiService, ) { } @Cron('0 9 * * *') // هم‌زمان با یادآوری اشتراک کانال async handleExpiryReminders(): Promise { const soon = await this.botSubscriptionService.findExpiringSoon(3); for (const sub of soon) { try { await this.telegramApiService.sendMessageWithKeyboard( sub.owner.user.telegramId, [ `⏳ اشتراک ربات شما تا ${formatPersianDate(sub.expiresAt)} اعتبار دارد.`, 'برای جلوگیری از قطع دسترسی به پنل، همین حالا تمدید کنید.', ].join('\n'), new InlineKeyboard().text('🔄 تمدید اشتراک ربات', Callback.BOT_SUBSCRIPTION.MENU), ); await this.botSubscriptionService.markReminderSent(sub.id); } catch (error) { this.logger.error(`Failed to send bot subscription reminder for ${sub.id}`, error); } } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\bot-subscriptions\services\bot-subscription.service.ts ############################################################ . import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { BotPlanService } from 'src/modules/bot-plans/services/bot-plan.service'; import { addDays, addMonths, addYears } from 'src/common/utils/date.util'; @Injectable() export class BotSubscriptionService { constructor( private readonly prisma: PrismaService, private readonly botPlanService: BotPlanService, ) { } async isActive(ownerId: string): Promise { const active = await this.prisma.botSubscription.findFirst({ where: { ownerId, status: 'ACTIVE', expiresAt: { gt: new Date() } }, }); return Boolean(active); } async activate(ownerId: string, botPlanId: string) { const plan = await this.botPlanService.findById(botPlanId); if (!plan) { throw new NotFoundException('پلن اشتراک ربات پیدا نشد'); } const current = await this.prisma.botSubscription.findFirst({ where: { ownerId, status: 'ACTIVE', expiresAt: { gt: new Date() } }, }); const base = current ? current.expiresAt : new Date(); const expiresAt = this.addDuration(base, plan.durationUnit, plan.durationValue); if (current) { return this.prisma.botSubscription.update({ where: { id: current.id }, data: { expiresAt, botPlanId }, }); } return this.prisma.botSubscription.create({ data: { ownerId, botPlanId, status: 'ACTIVE', expiresAt }, }); } async hasAny(ownerId: string): Promise { const existing = await this.prisma.botSubscription.findFirst({ where: { ownerId } }); return Boolean(existing); } // 🆕 برای Cron یادآوری ۳ روز قبل از انقضا async findExpiringSoon(daysAhead: number) { const from = new Date(); const to = new Date(); to.setDate(to.getDate() + daysAhead); return this.prisma.botSubscription.findMany({ where: { status: 'ACTIVE', expiresAt: { gte: from, lte: to }, reminderSentAt: null, }, include: { owner: { include: { user: true } } }, }); } async markReminderSent(id: string) { return this.prisma.botSubscription.update({ where: { id }, data: { reminderSentAt: new Date() }, }); } private addDuration(base: Date, unit: 'DAY' | 'MONTH' | 'YEAR', value: number): Date { if (unit === 'DAY') return addDays(base, value); if (unit === 'MONTH') return addMonths(base, value); return addYears(base, value); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\broadcast\broadcast.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { BroadcastService } from './services/broadcast.service'; import { BroadcastRepository } from './repository/broadcast.repository'; import { BroadcastJob } from './jobs/broadcast.job'; import { TelegramCoreModule } from '../telegram/telegram_core.module'; @Module({ imports: [TelegramCoreModule], providers: [BroadcastService, BroadcastRepository, BroadcastJob], exports: [BroadcastService], }) export class BroadcastModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\broadcast\jobs\broadcast.job.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { BroadcastService } from '../services/broadcast.service'; @Injectable() export class BroadcastJob { constructor(private readonly broadcastService: BroadcastService) { } @Cron(CronExpression.EVERY_MINUTE) async handle() { await this.broadcastService.processDueBroadcasts(); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\broadcast\repository\broadcast.repository.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; @Injectable() export class BroadcastRepository { constructor(private readonly prisma: PrismaService) { } create(data: { ownerId: string; content: string; channelId?: string }) { return this.prisma.broadcast.create({ data: { ownerId: data.ownerId, content: data.content, channelId: data.channelId, status: 'SCHEDULED', scheduledAt: new Date(), // طبق سند: ارسال فوری = SCHEDULED با زمان الان }, }); } findDue() { return this.prisma.broadcast.findMany({ where: { status: 'SCHEDULED', scheduledAt: { lte: new Date() } }, }); } markSending(id: string) { return this.prisma.broadcast.update({ where: { id }, data: { status: 'SENDING' } }); } markDone(id: string, sentCount: number) { return this.prisma.broadcast.update({ where: { id }, data: { status: 'DONE', sentCount } }); } // 🔑 گیرندگان یک کانال خاص findRecipientsByChannel(channelId: string) { return this.prisma.subscription .findMany({ where: { channelId, status: 'ACTIVE' }, select: { user: { select: { telegramId: true } } }, // 🆕 فقط telegramId distinct: ['userId'], }) .then((subs) => subs.map((s) => s.user)); } // 🔑 طبق تصمیم بالا: همه‌ی مشترکین فعال Owner در همه‌ی کانال‌هایش findRecipientsByOwner(ownerId: string) { return this.prisma.subscription .findMany({ where: { channel: { ownerId }, status: 'ACTIVE' }, select: { user: { select: { telegramId: true } } }, // 🆕 distinct: ['userId'], }) .then((subs) => subs.map((s) => s.user)); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\broadcast\services\broadcast.service.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { BroadcastRepository } from '../repository/broadcast.repository'; import { TelegramApiService } from 'src/modules/telegram/services/telegram-api.service'; @Injectable() export class BroadcastService { private readonly logger = new Logger(BroadcastService.name); constructor( private readonly repo: BroadcastRepository, private readonly telegramApiService: TelegramApiService, ) { } create(input: { ownerId: string; content: string; channelId?: string }) { return this.repo.create(input); } async processDueBroadcasts(): Promise { const due = await this.repo.findDue(); for (const broadcast of due) { await this.send(broadcast.id, broadcast.ownerId, broadcast.content, broadcast.channelId ?? undefined); } } private async send(broadcastId: string, ownerId: string, content: string, channelId?: string): Promise { await this.repo.markSending(broadcastId); const recipients = channelId ? await this.repo.findRecipientsByChannel(channelId) : await this.repo.findRecipientsByOwner(ownerId); let sentCount = 0; for (const user of recipients) { try { await this.telegramApiService.sendMessage(user.telegramId, content); sentCount += 1; } catch (error) { this.logger.warn(`Failed to send broadcast to ${user.telegramId}`, error); } // جلوگیری از Rate Limit تلگرام (~۳۰ پیام بر ثانیه) await new Promise((resolve) => setTimeout(resolve, 40)); } await this.repo.markDone(broadcastId, sentCount); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\channel-bans\channel-ban.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { ChannelBanService } from './services/channel-ban.service'; @Module({ providers: [ChannelBanService], exports: [ChannelBanService], }) export class ChannelBanModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\channel-bans\services\channel-ban.service.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; @Injectable() export class ChannelBanService { constructor(private readonly prisma: PrismaService) { } async ban(channelId: string, userId: string, reason: string, bannedUntil: Date | null) { // 🔑 upsert چون ممکنه قبلاً یه رکورد غیرفعال (رفع‌بن‌شده) داشته باشیم return this.prisma.channelMemberBan.upsert({ where: { channelId_userId: { channelId, userId } }, create: { channelId, userId, reason, bannedUntil, isActive: true }, update: { reason, bannedUntil, isActive: true }, }); } async unban(channelId: string, userId: string) { return this.prisma.channelMemberBan.updateMany({ where: { channelId, userId }, data: { isActive: false }, }); } // 🔑 چک سریع هنگام خرید یا join request async isBanned(channelId: string, userId: string): Promise { const ban = await this.prisma.channelMemberBan.findUnique({ where: { channelId_userId: { channelId, userId } }, }); if (!ban || !ban.isActive) return false; if (!ban.bannedUntil) return true; // دائم return ban.bannedUntil > new Date(); } async listByChannel(channelId: string, page = 0, pageSize = 20) { return this.prisma.channelMemberBan.findMany({ where: { channelId, isActive: true }, include: { user: true }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\channels\channel.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { ChannelService } from './services/channel.service'; import { ChannelRepository } from './repository/channel.repository'; @Module({ providers: [ChannelService, ChannelRepository], exports: [ChannelService], }) export class ChannelModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\channels\repository\channel.repository.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; @Injectable() export class ChannelRepository { constructor(private readonly prisma: PrismaService) { } create(data: { telegramChannelId: string; title: string; username?: string; ownerId: string; }) { return this.prisma.channel.create({ data }); } // بدون فیلتر isActive — چون برای چک یکتایی telegramChannelId لازمه // کانال رو حتی اگه غیرفعال باشه پیدا کنه (برای reactivate) findByTelegramId(telegramChannelId: string) { return this.prisma.channel.findUnique({ where: { telegramChannelId }, }); } findById(id: string) { return this.prisma.channel.findUnique({ where: { id }, }); } // 🆕 فقط کانال‌های فعال Owner findByOwnerId(ownerId: string) { return this.prisma.channel.findMany({ where: { ownerId, isActive: true }, }); } // 🆕 برای تشخیص نقش OWNER، فقط کانال فعال حساب می‌شه countByOwner(ownerId: string) { return this.prisma.channel.count({ where: { ownerId, isActive: true }, }); } // 🔧 قبلاً prisma.channel.delete() بود (حذف فیزیکی) — // چون Plan/Subscription/Broadcast به Channel رفرنس می‌دن و onDelete // تعریف نشده، حذف فیزیکی با اولین Plan/Subscription خطای FK می‌داد. deactivate(id: string) { return this.prisma.channel.update({ where: { id }, data: { isActive: false }, }); } // 🆕 وقتی ربات دوباره به یک کانال قبلاً حذف‌شده اضافه می‌شه reactivate(id: string, data: { title: string; username?: string; ownerId: string }) { return this.prisma.channel.update({ where: { id }, data: { ...data, isActive: true }, }); } listSellableChannels() { return this.prisma.channel.findMany({ where: { isActive: true, plans: { some: { isActive: true } } }, select: { id: true, title: true }, }); } findByTelegramIdWithOwner(telegramChannelId: string) { return this.prisma.channel.findUnique({ where: { telegramChannelId }, include: { owner: { include: { user: true } } }, }); } // برای پنل ادمین — عمداً همه‌ی کانال‌ها (فعال/غیرفعال) رو نشون می‌ده findAllWithOwner(page = 0, pageSize = 20) { return this.prisma.channel.findMany({ select: { id: true, title: true, isActive: true, owner: { select: { displayName: true, user: { select: { username: true, telegramId: true } }, }, }, }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } countAll() { return this.prisma.channel.count(); } findByOwnerIdPaginated(ownerId: string, page = 0, pageSize = 10) { return this.prisma.channel.findMany({ where: { ownerId, isActive: true }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\channels\services\channel.service.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { ChannelRepository } from '../repository/channel.repository'; @Injectable() export class ChannelService { constructor(private readonly repo: ChannelRepository) { } /** * اگه کانال قبلاً وجود نداشت: می‌سازه. * اگه وجود داشت ولی غیرفعال بود (قبلاً حذف شده): reactivate می‌کنه. * اگه وجود داشت و فعال بود: همون رو برمی‌گردونه (idempotent). */ async createChannel(data: { telegramChannelId: string; title: string; username?: string; ownerId: string; }) { const exists = await this.repo.findByTelegramId(data.telegramChannelId); if (!exists) return this.repo.create(data); if (!exists.isActive) { return this.repo.reactivate(exists.id, { title: data.title, username: data.username, ownerId: data.ownerId, }); } return exists; } async findByTelegramChannelId(telegramChannelId: string) { return this.repo.findByTelegramId(telegramChannelId); } async findById(id: string) { return this.repo.findById(id); } async getOwnerChannels(ownerId: string) { return this.repo.findByOwnerId(ownerId); } async countByOwner(ownerId: string): Promise { return this.repo.countByOwner(ownerId); } // 🔧 قبلاً remove() نام داشت و حذف فیزیکی می‌کرد async deactivate(id: string) { return this.repo.deactivate(id); } listSellableChannels() { return this.repo.listSellableChannels(); } findByTelegramIdWithOwner(telegramChannelId: string) { return this.repo.findByTelegramIdWithOwner(telegramChannelId); } listAllWithOwners(page = 0, pageSize = 20) { return this.repo.findAllWithOwner(page, pageSize); } countAll() { return this.repo.countAll(); } async getOwnerChannelsPaginated(ownerId: string, page = 0, pageSize = 10) { return this.repo.findByOwnerIdPaginated(ownerId, page, pageSize); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\discounts\discount.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { DiscountService } from './services/discount.service'; import { DiscountRepository } from './repository/discount.repository'; @Module({ providers: [DiscountService, DiscountRepository], exports: [DiscountService], }) export class DiscountModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\discounts\repository\discount.repository.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; @Injectable() export class DiscountRepository { constructor(private readonly prisma: PrismaService) { } findByCode(code: string) { return this.prisma.discountCode.findUnique({ where: { code } }); } incrementUsage(discountId: string) { return this.prisma.discountCode.update({ where: { id: discountId }, data: { usedCount: { increment: 1 } }, }); } create(data: { code: string; type: 'PERCENTAGE' | 'FIXED'; value: number; ownerId: string | null; scope: 'CHANNEL_PLAN' | 'BOT_PLAN'; planId?: string; botPlanId?: string; maxUsage?: number; expiresAt?: Date; isActive: boolean; }) { return this.prisma.discountCode.create({ data }); } findByOwner(ownerId: string) { return this.prisma.discountCode.findMany({ where: { ownerId }, include: { plan: true }, orderBy: { createdAt: 'desc' }, }); } findByIdForOwner(id: string, ownerId: string) { return this.prisma.discountCode.findFirst({ where: { id, ownerId } }); } deactivate(id: string) { return this.prisma.discountCode.update({ where: { id }, data: { isActive: false } }); } findGlobal() { return this.prisma.discountCode.findMany({ where: { ownerId: null }, orderBy: { createdAt: 'desc' }, }); } findByIdGlobal(id: string) { return this.prisma.discountCode.findFirst({ where: { id, ownerId: null } }); } findByOwnerPaginated(ownerId: string, page = 0, pageSize = 10) { return this.prisma.discountCode.findMany({ where: { ownerId }, include: { plan: true }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } countByOwner(ownerId: string): Promise { return this.prisma.discountCode.count({ where: { ownerId } }); } findGlobalPaginated(page = 0, pageSize = 10) { return this.prisma.discountCode.findMany({ where: { ownerId: null }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } countGlobal(): Promise { return this.prisma.discountCode.count({ where: { ownerId: null } }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\discounts\services\discount.service.ts ############################################################ . import { Injectable, BadRequestException } from '@nestjs/common'; import { DiscountRepository } from '../repository/discount.repository'; @Injectable() export class DiscountService { constructor(private readonly repo: DiscountRepository) { } /** * طبق سند بخش ۵.۲: بررسی می‌کند آیا این کد برای این پلن خاص معتبر است یا نه. * اگر معتبر نبود null برمی‌گرداند (نه Exception)، چون این تابع قراره * تو یک شرط ساده استفاده بشه، نه در یک مسیر خطا. */ async validateForPlan(code: string, planId: string) { const discount = await this.repo.findByCode(code); if (!discount) return null; if (!discount.isActive) return null; if (discount.expiresAt && discount.expiresAt < new Date()) return null; if (discount.maxUsage !== null && discount.usedCount >= discount.maxUsage) return null; if (discount.scope !== 'CHANNEL_PLAN') return null; // اگر کد به یک پلن خاص محدود شده، باید دقیقاً همان پلن باشد if (discount.planId && discount.planId !== planId) return null; return discount; } /** * محاسبه‌ی مبلغ نهایی بعد از اعمال تخفیف — طبق فرمول سند بخش ۵.۲ قدم ۵ */ calculateFinalAmount(amount: number, discount: { type: string; value: number }): number { if (discount.type === 'PERCENTAGE') { return Math.round(amount * (1 - discount.value / 100)); } return Math.max(0, amount - discount.value); } incrementUsage(discountId: string) { return this.repo.incrementUsage(discountId); } async createForOwner(input: { code: string; type: 'PERCENTAGE' | 'FIXED'; value: number; ownerId: string; planId: string; maxUsage?: number; expiresAt?: Date; }) { return this.repo.create({ ...input, scope: 'CHANNEL_PLAN', isActive: true }); } listByOwner(ownerId: string) { return this.repo.findByOwner(ownerId); } async deactivateForOwner(id: string, ownerId: string): Promise { const discount = await this.repo.findByIdForOwner(id, ownerId); if (!discount) return false; await this.repo.deactivate(id); return true; } findByCode(code: string) { return this.repo.findByCode(code); } async validateForBotPlan(code: string, botPlanId: string) { const discount = await this.repo.findByCode(code); if (!discount) return null; if (!discount.isActive) return null; if (discount.expiresAt && discount.expiresAt < new Date()) return null; if (discount.maxUsage !== null && discount.usedCount >= discount.maxUsage) return null; if (discount.scope !== 'BOT_PLAN') return null; if (discount.botPlanId && discount.botPlanId !== botPlanId) return null; return discount; } async createGlobal(input: { code: string; type: 'PERCENTAGE' | 'FIXED'; value: number; scope: 'CHANNEL_PLAN' | 'BOT_PLAN'; maxUsage?: number; expiresAt?: Date; }) { return this.repo.create({ ...input, ownerId: null, isActive: true }); } listGlobal() { return this.repo.findGlobal(); } async deactivateGlobal(id: string): Promise { const discount = await this.repo.findByIdGlobal(id); if (!discount) return false; await this.repo.deactivate(id); return true; } listByOwnerPaginated(ownerId: string, page = 0, pageSize = 10) { return this.repo.findByOwnerPaginated(ownerId, page, pageSize); } countByOwner(ownerId: string): Promise { return this.repo.countByOwner(ownerId); } listGlobalPaginated(page = 0, pageSize = 10) { return this.repo.findGlobalPaginated(page, pageSize); } countGlobal(): Promise { return this.repo.countGlobal(); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\owners\owner.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { OwnerService } from './services/owner.service'; @Module({ providers: [OwnerService], exports: [OwnerService], }) export class OwnerModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\owners\services\owner.service.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { Owner } from '@prisma/client'; @Injectable() export class OwnerService { constructor(private readonly prisma: PrismaService) { } async resolveOrCreateOwner(userId: string, displayName: string): Promise { const existing = await this.prisma.owner.findUnique({ where: { userId } }); if (!existing) { return this.prisma.owner.create({ data: { userId, displayName, status: 'ACTIVE' }, }); } if (existing.displayName === displayName) return existing; return this.prisma.owner.update({ where: { userId }, data: { displayName }, }); } async findByUserId(userId: string) { return this.prisma.owner.findUnique({ where: { userId }, }); } async findAllPaginated(page = 0, pageSize = 20) { return this.prisma.owner.findMany({ where: { user: { role: { not: 'ADMIN' } } }, select: { id: true, displayName: true, banStatus: true, banReason: true, bannedUntil: true, createdAt: true, user: { select: { telegramId: true, username: true } }, _count: { select: { channels: true } }, }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } async countAll(): Promise { return this.prisma.owner.count({ where: { user: { role: { not: 'ADMIN' } } } }); // 🔧 } async findByIdWithUser(id: string) { return this.prisma.owner.findUnique({ where: { id }, include: { user: true } }); } async ban(ownerId: string, status: 'TEMPORARY' | 'PERMANENT', reason: string, bannedUntil: Date | null) { return this.prisma.owner.update({ where: { id: ownerId }, data: { banStatus: status, banReason: reason, bannedUntil }, }); } async unban(ownerId: string) { return this.prisma.owner.update({ where: { id: ownerId }, data: { banStatus: 'NONE', banReason: null, bannedUntil: null }, }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\payment-requests\payment-request.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { PaymentRequestService } from './services/payment-request.service'; import { PaymentRequestExpiryJob } from './jobs/payment-request-expiry.job'; import { TelegramCoreModule } from '../telegram/telegram_core.module'; @Module({ imports: [TelegramCoreModule], // چون Job به TelegramApiService نیاز داره providers: [PaymentRequestService, PaymentRequestExpiryJob], exports: [PaymentRequestService], }) export class PaymentRequestModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\payment-requests\jobs\payment-request-expiry.job.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { PrismaService } from 'src/prisma/prisma.service'; import { TelegramApiService } from 'src/modules/telegram/services/telegram-api.service'; @Injectable() export class PaymentRequestExpiryJob { private readonly logger = new Logger(PaymentRequestExpiryJob.name); private isProcessing = false; // 🔒 همون قفل ساده‌ای که برای Subscription Expiry استفاده کردیم constructor( private readonly prisma: PrismaService, private readonly telegramApiService: TelegramApiService, ) { } @Cron(CronExpression.EVERY_10_MINUTES) async handleExpiredPaymentRequests(): Promise { if (this.isProcessing) return; this.isProcessing = true; try { // 🔑 فقط WaitingReceipt منقضی می‌شه — اگه فیش فرستاده (WaitingApproval)، منتظر تصمیم دریافت‌کننده می‌مونه، نه Cron const expired = await this.prisma.paymentRequest.findMany({ where: { status: 'WaitingReceipt', expiresAt: { lt: new Date() } }, include: { payer: true }, take: 100, }); await Promise.allSettled(expired.map((req) => this.processOne(req))); } finally { this.isProcessing = false; } } private async processOne(request: any): Promise { try { await this.prisma.paymentRequest.update({ where: { id: request.id }, data: { status: 'Expired' }, }); await this.telegramApiService.sendMessage( request.payer.telegramId, `⌛ درخواست پرداخت با کد ${request.paymentCode} به دلیل عدم ارسال فیش منقضی شد. در صورت تمایل دوباره اقدام کنید.`, ); } catch (error) { this.logger.error(`Failed to expire payment request ${request.id}`, error); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\payment-requests\services\payment-request.service.ts ############################################################ . import { Injectable, BadRequestException, Logger } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { generatePaymentCodePrefix, generateRandomCode } from 'src/common/utils/payment-code.util'; import { PaymentRequestType } from '@prisma/client'; import { addDays, addMonths, addYears } from 'src/common/utils/date.util'; const RECEIPT_DEADLINE_HOURS = 24; @Injectable() export class PaymentRequestService { private readonly logger = new Logger(PaymentRequestService.name); constructor(private readonly prisma: PrismaService) { } // 🔑 تولید شناسه یکتا با تلاش مجدد در صورت برخورد private async generateUniqueCode(type: PaymentRequestType): Promise { const prefix = generatePaymentCodePrefix(type); for (let attempt = 0; attempt < 5; attempt++) { const code = `${prefix}-${generateRandomCode()}`; const exists = await this.prisma.paymentRequest.findUnique({ where: { paymentCode: code } }); if (!exists) return code; } throw new Error('امکان تولید شناسه یکتای پرداخت وجود نداشت'); } // بعد async createForChannelPlan(input: { payerId: string; planId: string; amount: number; receiverOwnerId: string; bankCardId: string; subscriptionId?: string; renewOrUpgrade?: 'renew' | 'upgrade'; discountCodeId?: string; // 🆕 }) { const paymentCode = await this.generateUniqueCode('CHANNEL_SUBSCRIPTION'); return this.prisma.paymentRequest.create({ data: { paymentCode, type: 'CHANNEL_SUBSCRIPTION', amount: input.amount, payerId: input.payerId, planId: input.planId, receiverOwnerId: input.receiverOwnerId, bankCardId: input.bankCardId, subscriptionId: input.subscriptionId, renewOrUpgrade: input.renewOrUpgrade, discountCodeId: input.discountCodeId, // 🆕 status: 'WaitingReceipt', expiresAt: new Date(Date.now() + RECEIPT_DEADLINE_HOURS * 60 * 60 * 1000), }, }); } async createForBotPlan(input: { payerId: string; botPlanId: string; amount: number; receiverAdminUserId: string; bankCardId: string; discountCodeId?: string; // 🆕 }) { const paymentCode = await this.generateUniqueCode('BOT_SUBSCRIPTION'); return this.prisma.paymentRequest.create({ data: { paymentCode, type: 'BOT_SUBSCRIPTION', amount: input.amount, payerId: input.payerId, botPlanId: input.botPlanId, receiverAdminUserId: input.receiverAdminUserId, bankCardId: input.bankCardId, discountCodeId: input.discountCodeId, // 🆕 status: 'WaitingReceipt', expiresAt: new Date(Date.now() + RECEIPT_DEADLINE_HOURS * 60 * 60 * 1000), }, }); } // 🆕 مرحله‌ی «ارسال فیش» — چه اولین بار، چه دوباره بعد از رد شدن async submitReceipt(paymentRequestId: string, receiptFileId: string, payerId: string) { const request = await this.prisma.paymentRequest.findUnique({ where: { id: paymentRequestId } }); if (!request || request.payerId !== payerId) { throw new BadRequestException('درخواست پرداخت پیدا نشد'); } if (request.status !== 'WaitingReceipt' && request.status !== 'Rejected') { throw new BadRequestException('این درخواست در وضعیتی نیست که بشه فیش برایش ارسال کرد'); } return this.prisma.paymentRequest.update({ where: { id: paymentRequestId }, data: { receiptFileId, status: 'WaitingApproval', // طبق سند: رد شدن → دوباره WaitingApproval بعد از ارسال مجدد rejectReason: null, }, }); } async findById(id: string) { return this.prisma.paymentRequest.findUnique({ where: { id }, include: { payer: true, plan: { include: { channel: true } }, botPlan: true, discountCode: true }, }); } async cancel(paymentRequestId: string, payerId: string) { const request = await this.prisma.paymentRequest.findUnique({ where: { id: paymentRequestId } }); if (!request || request.payerId !== payerId) return false; // طبق سند: کاربر فقط تا قبل از ارسال فیش می‌تونه لغو کنه if (request.status !== 'WaitingReceipt') return false; await this.prisma.paymentRequest.update({ where: { id: paymentRequestId }, data: { status: 'Canceled' }, }); return true; } async approve(paymentRequestId: string, receiverOwnerId: string) { return this.prisma.$transaction(async (tx) => { // 🔒 آپدیت شرطی: فقط اگه هنوز WaitingApproval باشه تغییر می‌کنه — جلوگیری از تایید دوبار const updateResult = await tx.paymentRequest.updateMany({ where: { id: paymentRequestId, receiverOwnerId, status: 'WaitingApproval' }, data: { status: 'Approved' }, }); if (updateResult.count === 0) { throw new BadRequestException('این درخواست قبلاً پردازش شده یا متعلق به شما نیست'); } const request = await tx.paymentRequest.findUniqueOrThrow({ where: { id: paymentRequestId }, include: { plan: { include: { channel: true } }, payer: true }, }); // ساخت/تمدید/ارتقای اشتراک — دقیقاً همون منطق قبلی subscription-fulfillment let subscription; const plan = request.plan!; const expiresAt = this.calculateExpiry(plan.durationUnit, plan.durationValue); if (request.renewOrUpgrade === 'upgrade' && request.subscriptionId) { const current = await tx.subscription.findUniqueOrThrow({ where: { id: request.subscriptionId } }); subscription = await tx.subscription.update({ where: { id: request.subscriptionId }, data: { planId: plan.id, expiresAt, status: 'ACTIVE' }, }); } else if (request.renewOrUpgrade === 'renew' && request.subscriptionId) { const current = await tx.subscription.findUniqueOrThrow({ where: { id: request.subscriptionId } }); const base = current.expiresAt > new Date() ? current.expiresAt : new Date(); const extraMs = expiresAt.getTime() - Date.now(); subscription = await tx.subscription.update({ where: { id: request.subscriptionId }, data: { expiresAt: new Date(base.getTime() + extraMs), status: 'ACTIVE', renewedCount: { increment: 1 }, reminderSentAt: null, }, }); } else { subscription = await tx.subscription.create({ data: { userId: request.payerId, channelId: plan.channelId, planId: plan.id, expiresAt, status: 'ACTIVE' }, }); } await tx.transaction.create({ data: { userId: request.payerId, paymentRequestId: request.id, amount: request.amount, type: 'CHANNEL_SUBSCRIPTION', }, }); // 🆕 مصرف کد تخفیف فقط بعد از تایید قطعی if (request.discountCodeId) { await tx.discountCode.update({ where: { id: request.discountCodeId }, data: { usedCount: { increment: 1 } }, }); } return { request, subscription, plan }; }); } // بعد async findPendingByPayer(payerId: string) { return this.prisma.paymentRequest.findMany({ where: { payerId, status: { in: ['WaitingReceipt', 'Rejected'] } }, orderBy: { createdAt: 'desc' }, take: 10, }); } // 🆕 درخواست‌های در انتظار تایید که Owner دریافت‌کننده‌شونه async findPendingForOwner(ownerId: string) { return this.prisma.paymentRequest.findMany({ where: { receiverOwnerId: ownerId, status: 'WaitingApproval' }, orderBy: { createdAt: 'desc' }, take: 30, }); } // 🆕 درخواست‌های در انتظار تایید که Admin دریافت‌کننده‌شونه async findPendingForAdmin(adminUserId: string) { return this.prisma.paymentRequest.findMany({ where: { receiverAdminUserId: adminUserId, status: 'WaitingApproval' }, orderBy: { createdAt: 'desc' }, take: 30, }); } async reject(paymentRequestId: string, receiverOwnerId: string, reason: string) { const updateResult = await this.prisma.paymentRequest.updateMany({ where: { id: paymentRequestId, receiverOwnerId, status: 'WaitingApproval' }, data: { status: 'Rejected', rejectReason: reason }, }); if (updateResult.count === 0) { throw new BadRequestException('این درخواست قبلاً پردازش شده یا متعلق به شما نیست'); } return this.prisma.paymentRequest.findUnique({ where: { id: paymentRequestId }, include: { payer: true } }); } private calculateExpiry(unit: 'DAY' | 'MONTH' | 'YEAR', value: number): Date { const now = new Date(); if (unit === 'DAY') return addDays(now, value); if (unit === 'MONTH') return addMonths(now, value); return addYears(now, value); } async approveBotSubscription(paymentRequestId: string, receiverAdminUserId: string) { return this.prisma.$transaction(async (tx) => { const updateResult = await tx.paymentRequest.updateMany({ where: { id: paymentRequestId, receiverAdminUserId, status: 'WaitingApproval' }, data: { status: 'Approved' }, }); if (updateResult.count === 0) { throw new BadRequestException('این درخواست قبلاً پردازش شده یا متعلق به شما نیست'); } const request = await tx.paymentRequest.findUniqueOrThrow({ where: { id: paymentRequestId }, include: { botPlan: true, payer: true }, }); const botPlan = request.botPlan!; const owner = await tx.owner.findUniqueOrThrow({ where: { userId: request.payerId } }); const durationMs = this.toDurationMs(botPlan.durationUnit, botPlan.durationValue); const current = await tx.botSubscription.findFirst({ where: { ownerId: owner.id, status: 'ACTIVE', expiresAt: { gt: new Date() } }, }); const base = current ? current.expiresAt : new Date(); const expiresAt = new Date(base.getTime() + durationMs); if (current) { await tx.botSubscription.update({ where: { id: current.id }, data: { expiresAt, botPlanId: botPlan.id } }); } else { await tx.botSubscription.create({ data: { ownerId: owner.id, botPlanId: botPlan.id, status: 'ACTIVE', expiresAt } }); } await tx.transaction.create({ data: { userId: request.payerId, paymentRequestId: request.id, amount: request.amount, type: 'BOT_SUBSCRIPTION' }, }); if (request.discountCodeId) { await tx.discountCode.update({ where: { id: request.discountCodeId }, data: { usedCount: { increment: 1 } }, }); } return { request, expiresAt }; }); } private toDurationMs(unit: 'DAY' | 'MONTH' | 'YEAR', value: number): number { const now = new Date(); const future = unit === 'DAY' ? addDays(now, value) : unit === 'MONTH' ? addMonths(now, value) : addYears(now, value); return future.getTime() - now.getTime(); } async getReceiverOwnerTelegramId(paymentRequestId: string): Promise { const request = await this.prisma.paymentRequest.findUnique({ where: { id: paymentRequestId }, include: { receiverOwner: { include: { user: true } } }, }); return request?.receiverOwner?.user.telegramId ?? null; } async getReceiverAdminTelegramId(paymentRequestId: string): Promise { const request = await this.prisma.paymentRequest.findUnique({ where: { id: paymentRequestId }, include: { receiverAdminUser: true }, }); return request?.receiverAdminUser?.telegramId ?? null; } async rejectBotSubscription(paymentRequestId: string, receiverAdminUserId: string, reason: string) { const updateResult = await this.prisma.paymentRequest.updateMany({ where: { id: paymentRequestId, receiverAdminUserId, status: 'WaitingApproval' }, data: { status: 'Rejected', rejectReason: reason }, }); if (updateResult.count === 0) { throw new BadRequestException('این درخواست قبلاً پردازش شده یا متعلق به شما نیست'); } return this.prisma.paymentRequest.findUnique({ where: { id: paymentRequestId }, include: { payer: true } }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\plans\plan.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { PlanService } from './services/plan.service'; import { PlanRepository } from './repository/plan.repository'; @Module({ providers: [PlanService, PlanRepository], exports: [PlanService], }) export class PlanModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\plans\repository\plan.repository.ts ############################################################ . // plan.repository.ts import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { DurationUnit } from '@prisma/client'; @Injectable() export class PlanRepository { constructor(private readonly prisma: PrismaService) { } create(data: { channelId: string; title: string; price: number; durationUnit: DurationUnit; durationValue: number; }) { return this.prisma.plan.create({ data }); } findByIdWithChannelOwner(id: string) { return this.prisma.plan.findUnique({ where: { id }, include: { channel: true }, }); } findByChannelAndTitle(channelId: string, title: string) { return this.prisma.plan.findFirst({ where: { channelId, title: { equals: title, mode: 'insensitive' } }, }); } findByChannelId(channelId: string) { return this.prisma.plan.findMany({ where: { channelId } }); } findByOwnerId(ownerId: string) { return this.prisma.plan.findMany({ where: { channel: { ownerId } }, include: { channel: true }, }); } findByOwnerIdPaginated(ownerId: string, page = 0, pageSize = 10) { return this.prisma.plan.findMany({ where: { channel: { ownerId } }, include: { channel: { select: { title: true } } }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } countByOwner(ownerId: string) { return this.prisma.plan.count({ where: { channel: { ownerId } } }); } findById(id: string) { return this.prisma.plan.findUnique({ where: { id } }); } update(id: string, data: Partial<{ title: string; price: number; durationValue: number }>) { return this.prisma.plan.update({ where: { id }, data }); } remove(id: string) { return this.prisma.plan.delete({ where: { id } }); } // 🔧 فقط پلن‌های کانالی که خودش هم فعاله قابل خریدن getActiveChannelPlans(channelId: string) { return this.prisma.plan.findMany({ where: { channelId, isActive: true, channel: { isActive: true } }, orderBy: { price: 'asc' }, }); } findByIdWithChannel(id: string) { return this.prisma.plan.findUnique({ where: { id }, include: { channel: true }, }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\plans\services\plan.service.ts ############################################################ . // plan.service.ts import { Injectable } from '@nestjs/common'; import { PlanRepository } from '../repository/plan.repository'; import { DurationUnit } from '@prisma/client'; @Injectable() export class PlanService { constructor(private readonly repo: PlanRepository) { } async findByChannelAndTitle(channelId: string, title: string) { return this.repo.findByChannelAndTitle(channelId, title); } create(data: { channelId: string; title: string; price: number; durationUnit: DurationUnit; durationValue: number; }) { return this.repo.create(data); } getOwnerPlans(ownerId: string) { return this.repo.findByOwnerId(ownerId).then((plans) => plans.map((p) => ({ ...p, price: Number(p.price) })), ); } getChannelPlans(channelId: string) { return this.repo.findByChannelId(channelId); } async findById(planId: string) { const plan = await this.repo.findById(planId); if (!plan) return null; return { ...plan, price: Number(plan.price) }; } async update(planId: string, data: Partial<{ title: string; price: number; durationValue: number }>) { return this.repo.update(planId, data); } async getOwnerPlansPaginated(ownerId: string, page = 0, pageSize = 10) { const plans = await this.repo.findByOwnerIdPaginated(ownerId, page, pageSize); return plans.map((p) => ({ ...p, price: Number(p.price) })); } countByOwner(ownerId: string) { return this.repo.countByOwner(ownerId); } remove(planId: string) { return this.repo.remove(planId); } async findByIdWithChannelOwner(planId: string) { const plan = await this.repo.findByIdWithChannelOwner(planId); if (!plan) return null; return { ...plan, price: Number(plan.price) }; } async getChannelPlansForPurchase(channelId: string) { const plans = await this.repo.getActiveChannelPlans(channelId); return plans.map((p) => ({ ...p, price: Number(p.price) })); } async findByIdForOwner(planId: string, ownerId: string) { const plan = await this.repo.findByIdWithChannel(planId); if (!plan || plan.channel.ownerId !== ownerId) return null; return { ...plan, price: Number(plan.price) }; } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\service-outage\service-outage.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { ServiceOutageService } from './services/service-outage.service'; import { ServiceOutageRepository } from './repository/service-outage.repository'; import { OutageHealthCheckJob } from './jobs/outage-health-check.job'; import { OutageConfirmationTimeoutJob } from './jobs/outage-confirmation-timeout.job'; import { TelegramCoreModule } from '../telegram/telegram_core.module'; @Module({ imports: [ TelegramCoreModule ], // 🔧 SubscriptionModule/BotSubscriptionModule حذف شد providers: [ ServiceOutageService, ServiceOutageRepository, OutageHealthCheckJob, OutageConfirmationTimeoutJob ], exports: [ ServiceOutageService, ], }) export class ServiceOutageModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\service-outage\jobs\outage-confirmation-timeout.job.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ServiceOutageService } from '../services/service-outage.service'; @Injectable() export class OutageConfirmationTimeoutJob { private readonly logger = new Logger(OutageConfirmationTimeoutJob.name); constructor(private readonly outageService: ServiceOutageService) { } // هر ۱۵ دقیقه کافیه — نیازی به دقت بالا نیست @Cron(CronExpression.EVERY_10_MINUTES) async handle(): Promise { const expired = await this.outageService.findExpiredPending(); await Promise.allSettled( expired.map(async (outage) => { this.logger.warn(`Outage ${outage.id} auto-confirmed after 24h timeout`); await this.outageService.confirm(outage.id, 'AUTO_TIMEOUT'); }), ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\service-outage\jobs\outage-health-check.job.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { TelegramService } from 'src/modules/telegram/services/telegram.service'; import { ServiceOutageService } from '../services/service-outage.service'; import { getAdminTelegramIds } from 'src/modules/telegram/config/admin.config'; import { InlineKeyboard } from 'grammy'; import { Callback } from 'src/modules/telegram/constants/callback'; @Injectable() export class OutageHealthCheckJob { private readonly logger = new Logger(OutageHealthCheckJob.name); private isRunning = false; // 🔒 قفل هم‌پوشانی، طبق الگوی همیشگی پروژه constructor( private readonly telegramService: TelegramService, private readonly outageService: ServiceOutageService, ) { } // هر ۱ دقیقه — سبک‌ترین درخواست ممکن به تلگرام @Cron(CronExpression.EVERY_MINUTE) async handle(): Promise { if (process.env.OUTAGE_AUTO_DETECT_ENABLED !== 'true') return; // 🔧 غیرفعال به‌صورت پیش‌فرض، کد دست‌نخورده باقی مونده if (this.isRunning) return; this.isRunning = true; try { const isConnected = await this.checkConnectivity(); const open = await this.outageService.findOpen(); if (!isConnected && !open) { // 🆕 قطعی تازه شروع شده await this.onOutageDetected(); return; } if (!isConnected && open && !open.notifiedAdminTelegramId) { await this.tryNotifyAdmin(open.id); } if (isConnected && open) { // 🆕 اینترنت وصل شد await this.outageService.markReconnected(open.id, new Date()); this.logger.log(`Reconnected — outage ${open.id} marked`); } } finally { this.isRunning = false; } } private async checkConnectivity(): Promise { try { await this.telegramService.getBot().api.getMe(); return true; } catch { return false; } } private async onOutageDetected(): Promise { const outage = await this.outageService.create(new Date()); this.logger.warn(`Outage detected at ${outage.detectedAt.toISOString()}`); await this.tryNotifyAdmin(outage.id); } // 🔑 چون خودِ تلگرام قطعه، این تلاش احتمالاً همین الان شکست می‌خوره. // به همین دلیل این متد رو از خودِ Cron هم صدا می‌زنیم (پایین) تا هر دقیقه // دوباره تلاش کنه، تا وقتی که یه لحظه اتصال جزئی برقرار بشه و پیام بره. async tryNotifyAdmin(outageId: string): Promise { const outage = await this.outageService.findById(outageId); // یا یه متد findById عمومی تو Service اضافه کن if (!outage || outage.notifiedAdminTelegramId) return; // قبلاً موفق شده const adminIds = getAdminTelegramIds(); if (!adminIds.length) return; const text = [ '⚠️ سیستم تشخیص داد ارتباط با تلگرام قطع شده است.', `⏰ زمان تشخیص: ${outage.detectedAt.toLocaleString('fa-IR')}`, '', 'اگه این یک قطعی واقعیه، نیازی به کاری نیست — سیستم خودکار مدیریتش می‌کنه.', 'اگه فکر می‌کنید اشتباهه (مثلاً یک خطای موقتی بوده)، رد کنید.', '', '⏳ در صورت عدم پاسخ تا ۲۴ ساعت، به‌صورت خودکار تایید می‌شود.', ].join('\n'); const keyboard = new InlineKeyboard() .text('✅ بله، قطعی واقعی است', `${Callback.OUTAGE.CONFIRM}:${outageId}`) .text('❌ خیر، اشتباه تشخیص داده شد', `${Callback.OUTAGE.REJECT}:${outageId}`); for (const adminId of adminIds) { try { await this.telegramService.getBot().api.sendMessage(adminId, text, { reply_markup: keyboard }); await this.outageService.markNotified(outageId, adminId); return; // کافیه یکی موفق بشه } catch { // این ادمین هم الان قابل دسترسی نیست، بعدی رو امتحان کن (یا دور بعد Cron) continue; } } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\service-outage\repository\service-outage.repository.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; @Injectable() export class ServiceOutageRepository { constructor(private readonly prisma: PrismaService) { } // 🔑 آیا الان یک قطعی باز (هنوز reconnect نشده) داریم؟ findOpen() { return this.prisma.serviceOutage.findFirst({ where: { reconnectedAt: null, status: { in: ['PENDING_CONFIRMATION', 'CONFIRMED'] } }, orderBy: { detectedAt: 'desc' }, }); } create(detectedAt: Date) { return this.prisma.serviceOutage.create({ data: { detectedAt, confirmationDeadline: new Date(detectedAt.getTime() + 24 * 60 * 60 * 1000), status: 'PENDING_CONFIRMATION', }, }); } markNotified(id: string, adminTelegramId: string) { return this.prisma.serviceOutage.update({ where: { id }, data: { notifiedAdminTelegramId: adminTelegramId }, }); } markReconnected(id: string, reconnectedAt: Date) { return this.prisma.serviceOutage.update({ where: { id }, data: { reconnectedAt }, }); } confirm(id: string, source: 'ADMIN' | 'AUTO_TIMEOUT') { return this.prisma.serviceOutage.update({ where: { id }, data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmationSource: source }, }); } reject(id: string) { return this.prisma.serviceOutage.update({ where: { id }, data: { status: 'REJECTED' } }); } findById(id: string) { return this.prisma.serviceOutage.findUnique({ where: { id } }); } // برای Cron تایم‌اوت: هرچی هنوز منتظره و مهلتش گذشته findExpiredPending() { return this.prisma.serviceOutage.findMany({ where: { status: 'PENDING_CONFIRMATION', confirmationDeadline: { lte: new Date() } }, }); } markApplied(id: string) { return this.prisma.serviceOutage.update({ where: { id }, data: { status: 'APPLIED', appliedAt: new Date() } }); } markFailed(id: string) { return this.prisma.serviceOutage.update({ where: { id }, data: { status: 'FAILED' } }); } findAffectedSubscriptions(detectedAt: Date) { return this.prisma.subscription.findMany({ where: { status: 'ACTIVE', expiresAt: { gt: detectedAt } }, include: { user: true, channel: true }, take: 100, }); } createManual(detectedAt: Date, reconnectedAt: Date) { return this.prisma.serviceOutage.create({ data: { detectedAt, confirmationDeadline: new Date(detectedAt.getTime() + 24 * 60 * 60 * 1000), confirmedAt: new Date(), confirmationSource: 'ADMIN', reconnectedAt, status: 'CONFIRMED', }, }); } findPendingApply() { return this.prisma.serviceOutage.findFirst({ where: { status: 'CONFIRMED', appliedAt: null }, orderBy: { createdAt: 'desc' }, }); } deleteById(id: string) { return this.prisma.serviceOutage.delete({ where: { id } }); } findAffectedBotSubscriptions(detectedAt: Date) { return this.prisma.botSubscription.findMany({ where: { status: 'ACTIVE', expiresAt: { gt: detectedAt } }, include: { owner: { include: { user: true } } }, take: 100, }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\service-outage\services\service-outage.service.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { ServiceOutageRepository } from '../repository/service-outage.repository'; @Injectable() export class ServiceOutageService { private readonly logger = new Logger(ServiceOutageService.name); constructor( private readonly prisma: PrismaService, private readonly repo: ServiceOutageRepository, ) { } findOpen() { return this.repo.findOpen(); } create(detectedAt: Date) { return this.repo.create(detectedAt); } markNotified(id: string, adminTelegramId: string) { return this.repo.markNotified(id, adminTelegramId); } async markReconnected(id: string, reconnectedAt: Date): Promise { await this.repo.markReconnected(id, reconnectedAt); const outage = await this.repo.findById(id); // 🔑 اگه از قبل تایید شده بود (ادمین سریع جواب داده بود)، همین الان اصلاح رو اعمال کن if (outage?.status === 'CONFIRMED') { await this.apply(id); } // اگه هنوز PENDING هست، صبر می‌کنیم تا تایید دستی یا تایم‌اوت } // 🔑 صدا زده می‌شه از دو جا: کلیک دکمه‌ی ادمین، یا Cron تایم‌اوت async confirm(id: string, source: 'ADMIN' | 'AUTO_TIMEOUT'): Promise { const outage = await this.repo.findById(id); if (!outage || outage.status !== 'PENDING_CONFIRMATION') return; // قبلاً پردازش شده await this.repo.confirm(id, source); // 🔑 اگه اینترنت از قبل وصل شده بود (reconnectedAt پر بود)، همین الان اعمال کن if (outage.reconnectedAt) { await this.apply(id); } // وگرنه هنوز قطعیه؛ وقتی reconnect شد، markReconnected اعمالش می‌کنه } async reject(id: string): Promise { const outage = await this.repo.findById(id); if (!outage || outage.status !== 'PENDING_CONFIRMATION') return; await this.repo.reject(id); } // 🔑 قلب اصلاح — Bulk Update، مقیاس‌پذیر private async apply(id: string): Promise { const outage = await this.repo.findById(id); if (!outage || !outage.reconnectedAt) return; if (outage.status === 'APPLIED') return; const durationMs = outage.reconnectedAt.getTime() - outage.detectedAt.getTime(); if (durationMs <= 0) { await this.repo.markApplied(id); return; } try { await this.prisma.$transaction(async (tx) => { await tx.$executeRaw` UPDATE "Subscription" SET "expiresAt" = "expiresAt" + (${durationMs} * interval '1 millisecond'), "reminderSentAt" = NULL WHERE status = 'ACTIVE' AND "expiresAt" > ${outage.detectedAt} `; await tx.$executeRaw` UPDATE "BotSubscription" SET "expiresAt" = "expiresAt" + (${durationMs} * interval '1 millisecond'), "reminderSentAt" = NULL WHERE status = 'ACTIVE' AND "expiresAt" > ${outage.detectedAt} `; }); await this.repo.markApplied(id); this.logger.log(`Outage ${id} applied — ${Math.round(durationMs / 60000)} minutes added`); } catch (error) { this.logger.error(`Failed to apply outage ${id}`, error); await this.repo.markFailed(id); } } // تو ServiceOutageService findById(id: string) { return this.repo.findById(id); } findExpiredPending() { return this.repo.findExpiredPending(); } async declareManually(): Promise { const outage = await this.repo.create(new Date()); await this.repo.confirm(outage.id, 'ADMIN'); // اعلام دستی = تایید فوری return outage.id; } async getReport(outageId: string) { const outage = await this.repo.findById(outageId); if (!outage) return null; const [subs, botSubs] = await Promise.all([ this.repo.findAffectedSubscriptions(outage.detectedAt), this.repo.findAffectedBotSubscriptions(outage.detectedAt), ]); return { outage, subs, botSubs }; } async declareManualOutage(start: Date, end: Date) { return this.repo.createManual(start, end); } async findPendingApply() { return this.repo.findPendingApply(); } async getReportForOutage(outageId: string) { const outage = await this.repo.findById(outageId); if (!outage) return null; const [subs, botSubs] = await Promise.all([ this.repo.findAffectedSubscriptions(outage.detectedAt), this.repo.findAffectedBotSubscriptions(outage.detectedAt), ]); return { outage, subs, botSubs }; } async discard(outageId: string): Promise { const outage = await this.repo.findById(outageId); if (!outage || outage.status === 'APPLIED') return; await this.repo.deleteById(outageId); } // 🆕 wrapper عمومی روی apply خصوصیِ موجود async applyOutage(id: string): Promise { await this.apply(id); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\stats\stats.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { StatsService } from './services/stats.service'; import { StatsRepository } from './repository/stats.repository'; @Module({ providers: [StatsService, StatsRepository], exports: [StatsService], }) export class StatsModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\stats\repository\stats.repository.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; @Injectable() export class StatsRepository { constructor(private readonly prisma: PrismaService) { } async getOwnerChannelIds(ownerId: string): Promise { const channels = await this.prisma.channel.findMany({ where: { ownerId }, select: { id: true }, }); return channels.map((c) => c.id); } async countActiveSubscriptions(channelIds: string[]): Promise { return this.prisma.subscription.count({ where: { channelId: { in: channelIds }, status: 'ACTIVE' }, }); } async getOwnerChannelsBasic(ownerId: string) { return this.prisma.channel.findMany({ where: { ownerId }, select: { id: true, title: true }, }); } async getChannelRenewalRates(channelIds: string[]) { const [total, renewed] = await Promise.all([ this.prisma.subscription.groupBy({ by: ['channelId'], where: { channelId: { in: channelIds } }, _count: true, }), this.prisma.subscription.groupBy({ by: ['channelId'], where: { channelId: { in: channelIds }, renewedCount: { gt: 0 } }, _count: true, }), ]); const totalMap = new Map(total.map((t) => [t.channelId, t._count])); const renewedMap = new Map(renewed.map((r) => [r.channelId, r._count])); const result = new Map(); for (const [channelId, totalCount] of totalMap) { const renewedCount = renewedMap.get(channelId) ?? 0; result.set(channelId, totalCount === 0 ? 0 : Math.round((renewedCount / totalCount) * 100)); } return result; } // 🆕 تعداد اعضای فعال و منقضی (فیکس‌شده طبق باگ ۶) هر کانال async getChannelActiveExpiredCounts(channelIds: string[]) { const [activeGrouped, activeSubs, expiredSubs] = await Promise.all([ this.prisma.subscription.groupBy({ by: ['channelId'], where: { channelId: { in: channelIds }, status: 'ACTIVE' }, _count: true, }), this.prisma.subscription.findMany({ where: { channelId: { in: channelIds }, status: 'ACTIVE' }, select: { userId: true, channelId: true }, }), this.prisma.subscription.findMany({ where: { channelId: { in: channelIds }, status: 'EXPIRED' }, select: { userId: true, channelId: true }, }), ]); const activeMap = new Map(activeGrouped.map((a) => [a.channelId, a._count])); const activeSet = new Set(activeSubs.map((s) => `${s.userId}:${s.channelId}`)); const expiredMap = new Map(); for (const s of expiredSubs) { if (activeSet.has(`${s.userId}:${s.channelId}`)) continue; expiredMap.set(s.channelId, (expiredMap.get(s.channelId) ?? 0) + 1); } return { activeMap, expiredMap }; } async getChannelRevenueBreakdown(channelIds: string[], from: Date) { const transactions = await this.prisma.transaction.findMany({ where: { type: 'CHANNEL_SUBSCRIPTION', createdAt: { gte: from }, paymentRequest: { plan: { channelId: { in: channelIds } } }, }, select: { amount: true, paymentRequest: { select: { plan: { select: { channelId: true } } } } }, }); const map = new Map(); for (const tx of transactions) { const channelId = tx.paymentRequest.plan!.channelId; const entry = map.get(channelId) ?? { revenue: 0, salesCount: 0 }; entry.revenue += tx.amount; entry.salesCount += 1; map.set(channelId, entry); } return map; } async countExpiredSubscriptions(channelIds: string[]): Promise { const [activeSubs, expiredSubs] = await Promise.all([ this.prisma.subscription.findMany({ where: { channelId: { in: channelIds }, status: 'ACTIVE' }, select: { userId: true, channelId: true }, }), this.prisma.subscription.findMany({ where: { channelId: { in: channelIds }, status: 'EXPIRED' }, select: { userId: true, channelId: true }, }), ]); const activeSet = new Set(activeSubs.map((s) => `${s.userId}:${s.channelId}`)); return expiredSubs.filter((s) => !activeSet.has(`${s.userId}:${s.channelId}`)).length; } async countNewSubscriptionsSince(channelIds: string[], since: Date): Promise { return this.prisma.subscription.count({ where: { channelId: { in: channelIds }, createdAt: { gte: since } }, }); } async sumRevenue(channelIds: string[], from: Date) { const result = await this.prisma.transaction.aggregate({ where: { type: 'CHANNEL_SUBSCRIPTION', createdAt: { gte: from }, paymentRequest: { plan: { channelId: { in: channelIds } } }, }, _sum: { amount: true }, _count: true, }); return result; } async getPlanSalesBreakdown(ownerId: string) { const transactions = await this.prisma.transaction.findMany({ where: { type: 'CHANNEL_SUBSCRIPTION', paymentRequest: { plan: { channel: { ownerId } } }, }, select: { amount: true, paymentRequest: { select: { planId: true, plan: { select: { title: true } } } }, }, }); const map = new Map(); for (const tx of transactions) { const planId = tx.paymentRequest.planId!; const entry = map.get(planId) ?? { title: tx.paymentRequest.plan?.title ?? 'نامشخص', revenue: 0, count: 0 }; entry.revenue += tx.amount; entry.count += 1; map.set(planId, entry); } return Array.from(map.entries()).map(([planId, v]) => ({ planId, title: v.title, revenue: v.revenue, count: v.count, })); } async countTotalSubscriptions(channelIds: string[]): Promise { return this.prisma.subscription.count({ where: { channelId: { in: channelIds } } }); } async countRenewedSubscriptions(channelIds: string[]): Promise { return this.prisma.subscription.count({ where: { channelId: { in: channelIds }, renewedCount: { gt: 0 } }, }); } async getPlanTitles(planIds: string[]) { return this.prisma.plan.findMany({ where: { id: { in: planIds } }, select: { id: true, title: true }, }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\stats\services\stats.service.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { StatsRepository } from '../repository/stats.repository'; const TEHRAN_OFFSET_MS = 3.5 * 60 * 60 * 1000; // UTC+3:30، بدون DST @Injectable() export class StatsService { constructor(private readonly repo: StatsRepository) { } async getOwnerOverview(channelIds: string[]) { // 🔧 حالا channelIds می‌گیره const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const [activeCount, expiredCount, newThisWeek] = await Promise.all([ this.repo.countActiveSubscriptions(channelIds), this.repo.countExpiredSubscriptions(channelIds), this.repo.countNewSubscriptionsSince(channelIds, weekAgo), ]); return { activeCount, expiredCount, newThisWeek }; } async getOwnerDashboard(ownerId: string) { const channelIds = await this.repo.getOwnerChannelIds(ownerId); // فقط یک بار const [overview, renewalRate, breakdown] = await Promise.all([ this.getOwnerOverview(channelIds), this.getRenewalRate(channelIds), this.getPlanSalesBreakdown(ownerId), ]); return { overview, renewalRate, breakdown }; } // 🆕 گزارش کامل عملکرد به‌تفکیک کانال async getChannelPerformanceReport(ownerId: string, range: 'daily' | 'weekly' | 'monthly' | 'yearly') { const channels = await this.repo.getOwnerChannelsBasic(ownerId); if (!channels.length) return { channels: [], best: null, worst: null }; const channelIds = channels.map((c) => c.id); const from = this.getRangeStartTehran(range); const [revenueMap, { activeMap, expiredMap }, renewalMap] = await Promise.all([ this.repo.getChannelRevenueBreakdown(channelIds, from), this.repo.getChannelActiveExpiredCounts(channelIds), this.repo.getChannelRenewalRates(channelIds), ]); const rows = channels.map((c) => ({ channelId: c.id, title: c.title, revenue: revenueMap.get(c.id)?.revenue ?? 0, salesCount: revenueMap.get(c.id)?.salesCount ?? 0, activeCount: activeMap.get(c.id) ?? 0, expiredCount: expiredMap.get(c.id) ?? 0, renewalRate: renewalMap.get(c.id) ?? 0, })); const sorted = [...rows].sort((a, b) => b.revenue - a.revenue); return { channels: rows, best: sorted[0] ?? null, worst: sorted.length > 1 ? sorted[sorted.length - 1] : null, }; } async getRevenue(ownerId: string, range: 'daily' | 'weekly' | 'monthly' | 'yearly') { // 🔧 weekly اضافه شد const channelIds = await this.repo.getOwnerChannelIds(ownerId); const from = this.getRangeStartTehran(range); const result = await this.repo.sumRevenue(channelIds, from); return { totalRevenue: result._sum.amount ?? 0, salesCount: result._count, }; } async getPlanSalesBreakdown(ownerId: string) { return this.repo.getPlanSalesBreakdown(ownerId); } async getRenewalRate(channelIds: string[]): Promise { // 🔧 حالا channelIds می‌گیره، نه ownerId const total = await this.repo.countTotalSubscriptions(channelIds); const renewed = await this.repo.countRenewedSubscriptions(channelIds); return total === 0 ? 0 : Math.round((renewed / total) * 100); } // شروع بازه رو به وقت تهران محاسبه می‌کنه، بعد به معادل UTC برمی‌گردونه // چون Prisma/Postgres همه‌چیز رو با UTC مقایسه می‌کنه. private getRangeStartTehran(range: 'daily' | 'weekly' | 'monthly' | 'yearly'): Date { // 🔧 weekly اضافه شد const nowTehran = new Date(Date.now() + TEHRAN_OFFSET_MS); let startTehran: Date; if (range === 'daily') { startTehran = new Date(Date.UTC(nowTehran.getUTCFullYear(), nowTehran.getUTCMonth(), nowTehran.getUTCDate())); } else if (range === 'weekly') { // 🆕 const dayOfWeek = nowTehran.getUTCDay(); // 0=یکشنبه در استاندارد جاوااسکریپت const daysSinceSaturday = (dayOfWeek + 1) % 7; // هفته‌ی ایرانی از شنبه شروع می‌شه startTehran = new Date(Date.UTC( nowTehran.getUTCFullYear(), nowTehran.getUTCMonth(), nowTehran.getUTCDate() - daysSinceSaturday, )); } else if (range === 'monthly') { startTehran = new Date(Date.UTC(nowTehran.getUTCFullYear(), nowTehran.getUTCMonth(), 1)); } else { startTehran = new Date(Date.UTC(nowTehran.getUTCFullYear(), 0, 1)); } return new Date(startTehran.getTime() - TEHRAN_OFFSET_MS); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\subscriptions\subscription.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { SubscriptionService } from './services/subscription.service'; import { SubscriptionRepository } from './repository/subscription.repository'; import { TelegramCoreModule } from '../telegram/telegram_core.module'; import { SubscriptionExpiryJob } from './jobs/subscription-expiry.job'; import { ServiceOutageModule } from '../service-outage/service-outage.module'; @Module({ imports: [ TelegramCoreModule, ServiceOutageModule, ], providers: [ SubscriptionService, SubscriptionRepository, SubscriptionExpiryJob, ], exports: [ SubscriptionService, ], }) export class SubscriptionModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\subscriptions\jobs\subscription-expiry.job.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { SubscriptionService } from '../services/subscription.service'; import { TelegramApiService } from 'src/modules/telegram/services/telegram-api.service'; import { formatPersianDate } from 'src/common/utils/date.util'; import { InlineKeyboard } from 'grammy'; import { Callback } from 'src/modules/telegram/constants/callback' import { ServiceOutageService } from 'src/modules/service-outage/services/service-outage.service'; @Injectable() export class SubscriptionExpiryJob { private readonly logger = new Logger(SubscriptionExpiryJob.name); private isProcessingExpiry = false; constructor( private readonly subscriptionService: SubscriptionService, private readonly telegramApiService: TelegramApiService, private readonly serviceOutageService: ServiceOutageService, ) { } @Cron(CronExpression.EVERY_30_SECONDS) async handleExpiredSubscriptions() { if (this.isProcessingExpiry) { this.logger.warn('اجرای قبلی هنوز تموم نشده، این دور رد می‌شه'); return; } // 🆕 اگه یک قطعی تایید/در انتظار داریم که هنوز reconnect نشده، این دور رو رد کن const openOutage = await this.serviceOutageService.findOpen(); if (openOutage) { this.logger.warn('سیستم در وضعیت قطعی است، این دور رد می‌شه'); return; } this.isProcessingExpiry = true; try { const expired = await this.subscriptionService.findExpired(); // پیش‌فرض 100 تا // 🆕 به‌جای حلقه‌ی سریالی، موازی با Promise.allSettled await Promise.allSettled( expired.map((subscription) => this.processOne(subscription)), ); } finally { this.isProcessingExpiry = false; } } private async processOne(subscription: any): Promise { try { await this.telegramApiService.banChatMember( subscription.channel.telegramChannelId, Number(subscription.user.telegramId), ); await this.telegramApiService.unbanChatMember( subscription.channel.telegramChannelId, Number(subscription.user.telegramId), ); await this.subscriptionService.markExpired(subscription.id); await this.telegramApiService.sendMessage( subscription.user.telegramId, `⌛ اشتراک شما در کانال «${subscription.channel.title}» منقضی شد و دسترسی شما حذف شد. برای تمدید از منوی اشتراک‌های من اقدام کنید. شروع مجدد /start `, ); this.logger.log(`Subscription ${subscription.id} expired and revoked`); } catch (error) { this.logger.error(`Failed to revoke subscription ${subscription.id}`, error); } } @Cron('0 9 * * *') async handleExpiryReminders() { const soon = await this.subscriptionService.findExpiringSoon(3); for (const subscription of soon) { try { await this.telegramApiService.sendMessageWithKeyboard( subscription.user.telegramId, [ `⏳ اشتراک شما در کانال «${subscription.channel.title}» تا ${formatPersianDate(subscription.expiresAt)} اعتبار دارد.`, 'برای جلوگیری از قطع دسترسی، همین حالا تمدید کنید.', ].join('\n'), new InlineKeyboard().text('🔄 تمدید اشتراک', `${Callback.SUBSCRIPTION.RENEW}:${subscription.id}`), ); await this.subscriptionService.markReminderSent(subscription.id); } catch (error) { this.logger.error(`Failed to send reminder for ${subscription.id}`, error); } } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\subscriptions\repository\subscription.repository.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; @Injectable() export class SubscriptionRepository { constructor(private readonly prisma: PrismaService) { } findActiveByUserAndChannel(userId: string, channelId: string) { return this.prisma.subscription.findFirst({ where: { userId, channelId, status: 'ACTIVE' }, }); } findById(id: string) { return this.prisma.subscription.findUnique({ where: { id }, include: { channel: true, plan: true, user: true }, }); } create(data: { userId: string; channelId: string; planId: string; expiresAt: Date }) { return this.prisma.subscription.create({ data: { ...data, status: 'ACTIVE' }, }); } async renew(subscriptionId: string, extraDurationMs: number) { const subscription = await this.prisma.subscription.findUnique({ where: { id: subscriptionId } }); if (!subscription) throw new Error('Subscription not found'); // طبق سند بخش ۵.۳: تاریخ جدید روی انقضای فعلی جمع می‌شود، نه از امروز const base = subscription.expiresAt > new Date() ? subscription.expiresAt : new Date(); return this.prisma.subscription.update({ where: { id: subscriptionId }, data: { expiresAt: new Date(base.getTime() + extraDurationMs), status: 'ACTIVE', renewedCount: { increment: 1 }, reminderSentAt: null, }, }); } upgrade(subscriptionId: string, newPlanId: string, newExpiresAt: Date) { return this.prisma.subscription.update({ where: { id: subscriptionId }, data: { planId: newPlanId, expiresAt: newExpiresAt, status: 'ACTIVE' }, }); } findExpired(now: Date = new Date(), limit = 100) { return this.prisma.subscription.findMany({ where: { status: 'ACTIVE', expiresAt: { lte: now, }, }, include: { user: true, channel: true, }, take: limit, }); } findExpiringSoon(daysAhead: number) { const from = new Date(); const to = new Date(); to.setDate(to.getDate() + daysAhead); return this.prisma.subscription.findMany({ where: { status: 'ACTIVE', expiresAt: { gte: from, lte: to }, reminderSentAt: null, // 🔑 تا یادآوری تکراری نره }, include: { user: true, channel: true }, }); } markExpired(id: string) { return this.prisma.subscription.update({ where: { id }, data: { status: 'EXPIRED' }, }); } markReminderSent(id: string) { return this.prisma.subscription.update({ where: { id }, data: { reminderSentAt: new Date() }, }); } findActiveByUser(userId: string) { return this.prisma.subscription.findMany({ where: { userId, status: 'ACTIVE' }, include: { channel: true, plan: true }, orderBy: { expiresAt: 'asc' }, }); } findActiveByChannel(channelId: string, page = 0, pageSize = 20) { return this.prisma.subscription.findMany({ where: { channelId, status: 'ACTIVE' }, include: { user: true }, orderBy: { expiresAt: 'asc' }, skip: page * pageSize, take: pageSize, }); } countActiveByChannel(channelId: string) { return this.prisma.subscription.count({ where: { channelId, status: 'ACTIVE' }, }); } async findExpiredByChannel(channelId: string) { const activeUsers = await this.prisma.subscription.findMany({ where: { channelId, status: 'ACTIVE' }, select: { userId: true }, }); const excludeIds = activeUsers.map((s) => s.userId); return this.prisma.subscription.findMany({ where: { channelId, status: 'EXPIRED', userId: { notIn: excludeIds }, }, include: { user: true }, orderBy: { updatedAt: 'desc' }, take: 100, }); } searchByChannelAndQuery(channelId: string, query: string) { return this.prisma.subscription.findMany({ where: { channelId, user: { OR: [ { telegramId: query }, // 🔧 مچ دقیق (سریع‌ترین) { username: { contains: query, mode: 'insensitive' } }, { firstName: { contains: query, mode: 'insensitive' } }, ], }, }, include: { user: true }, orderBy: { updatedAt: 'desc' }, take: 30, // 🆕 محدودیت برای مقیاس‌پذیری }); } findActiveByUserPaginated(userId: string, page = 0, pageSize = 10) { return this.prisma.subscription.findMany({ where: { userId, status: 'ACTIVE' }, include: { channel: true, plan: true }, orderBy: { expiresAt: 'asc' }, skip: page * pageSize, take: pageSize, }); } countActiveByUser(userId: string) { return this.prisma.subscription.count({ where: { userId, status: 'ACTIVE' } }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\subscriptions\services\subscription.service.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { SubscriptionRepository } from '../repository/subscription.repository'; import { TelegramApiService } from 'src/modules/telegram/services/telegram-api.service'; @Injectable() export class SubscriptionService { constructor( private readonly repo: SubscriptionRepository, private readonly telegramApiService: TelegramApiService, ) { } findActiveByUserAndChannel(userId: string, channelId: string) { return this.repo.findActiveByUserAndChannel(userId, channelId); } findById(id: string) { return this.repo.findById(id); } create(data: { userId: string; channelId: string; planId: string; expiresAt: Date }) { return this.repo.create(data); } renew(subscriptionId: string, extraDurationMs: number) { return this.repo.renew(subscriptionId, extraDurationMs); } upgrade(subscriptionId: string, newPlanId: string, newExpiresAt: Date) { return this.repo.upgrade(subscriptionId, newPlanId, newExpiresAt); } findExpired() { return this.repo.findExpired(); } findExpiringSoon(daysAhead: number) { return this.repo.findExpiringSoon(daysAhead); } markExpired(id: string) { return this.repo.markExpired(id); } markReminderSent(id: string) { return this.repo.markReminderSent(id); } findActiveByUser(userId: string) { return this.repo.findActiveByUser(userId); } findActiveByChannel(channelId: string, page = 0, pageSize = 20) { return this.repo.findActiveByChannel(channelId, page, pageSize); } countActiveByChannel(channelId: string) { return this.repo.countActiveByChannel(channelId); } findExpiredByChannel(channelId: string) { return this.repo.findExpiredByChannel(channelId); } searchByChannelAndQuery(channelId: string, query: string) { return this.repo.searchByChannelAndQuery(channelId, query); } async manualRemove(subscriptionId: string): Promise { const subscription = await this.repo.findById(subscriptionId); if (!subscription) return; await this.telegramApiService.banChatMember( subscription.channel.telegramChannelId, Number(subscription.user.telegramId), ); await this.telegramApiService.unbanChatMember( subscription.channel.telegramChannelId, Number(subscription.user.telegramId), ); await this.repo.markExpired(subscriptionId); } findActiveByUserPaginated(userId: string, page = 0, pageSize = 10) { return this.repo.findActiveByUserPaginated(userId, page, pageSize); } countActiveByUser(userId: string) { return this.repo.countActiveByUser(userId); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\telegram.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { DiscoveryModule } from '@nestjs/core'; import { TelegramCoreModule } from './telegram_core.module'; import { TelegramRegistry } from './registry/telegram.registry'; import { PingHandler } from './handlers/commands/ping.handler'; import { UsersModule } from '../users/users.module'; import { AuthMiddleware } from './middlewares/auth.middleware'; import { StartHandler } from './handlers/commands/start.handler'; import { OwnerModule } from '../owners/owner.module'; import { ChannelModule } from '../channels/channel.module'; import { BotPlanModule } from '../bot-plans/bot-plan.module'; import { BotSubscriptionModule } from '../bot-subscriptions/bot-subscription.module'; import { RoleMiddleware } from './middlewares/role.middleware'; import { SessionMiddleware } from './middlewares/session.middleware'; import { TelegramSessionService } from './services/telegram-session.service'; import { TelegramSessionRepository } from './repository/telegram-session.repository'; import { AddChannelHandler } from './handlers/commands/add-channel.handler'; import { ChannelUpdateHandler } from './handlers/channel.update'; import { UserMenuHandler } from './handlers/callbacks/user-menu.handler'; import { ChannelMenuHandler } from './handlers/callbacks/channel-menu.handler'; import { PlanModule } from '../plans/plan.module'; import { WaitingPlanPriceHandler } from './handlers/messages/waiting-plan-price.handler'; import { WaitingPlanDurationHandler } from './handlers/messages/waiting-plan-duration.handler'; import { WaitingPlanTitleHandler } from './handlers/messages/waiting-plan-title.handler'; import { PlanDurationUnitHandler } from './handlers/callbacks/plan-duration-unit.handler'; import { PlanMenuHandler } from './handlers/callbacks/plan-menu.handler'; import { MessageDispatcher } from './dispatcher/message.dispatcher'; import { PhotoDispatcher } from './dispatcher/photo.dispatcher'; import { WaitingPlanEditValueHandler } from './handlers/messages/waiting-plan-edit-value.handler'; import { SubscriptionModule } from '../subscriptions/subscription.module'; import { SubscriptionMenuHandler } from './handlers/callbacks/subscription-menu.handler'; import { DiscountModule } from '../discounts/discount.module'; import { JoinRequestHandler } from './handlers/join-request.handler'; import { BotSubscriptionHandler } from './handlers/callbacks/bot-subscription.handler'; import { WaitingBotPlanPriceHandler } from './handlers/messages/waiting-bot-plan-price.handler'; import { WaitingBotPlanDurationHandler } from './handlers/messages/waiting-bot-plan-duration.handler'; import { WaitingBotPlanTitleHandler } from './handlers/messages/waiting-bot-plan-title.handler'; import { AdminBotPlanDurationUnitHandler } from './handlers/callbacks/admin-bot-plan-duration-unit.handler'; import { AdminPanelHandler } from './handlers/callbacks/admin-panel.handler'; import { NavigationHandler } from './handlers/callbacks/navigation.handler'; import { NoopHandler } from './handlers/callbacks/noop.handler'; import { MemberMenuHandler } from './handlers/callbacks/member-menu.handler'; import { WaitingMemberSearchQueryHandler } from './handlers/messages/waiting-member-search-query.handler'; import { DiscountMenuHandler } from './handlers/callbacks/discount-menu.handler'; import { WaitingNewDiscountCodeHandler } from './handlers/messages/waiting-new-discount-code.handler'; import { WaitingNewDiscountValueHandler } from './handlers/messages/waiting-new-discount-value.handler'; import { WaitingNewDiscountMaxUsageHandler } from './handlers/messages/waiting-new-discount-max-usage.handler'; import { WaitingNewDiscountExpiryHandler } from './handlers/messages/waiting-new-discount-expiry.handler'; import { StatsModule } from '../stats/stats.module'; import { StatsHandler } from './handlers/callbacks/stats.handler'; import { WaitingBroadcastContentHandler } from './handlers/messages/waiting-broadcast-content.handler'; import { BroadcastHandler } from './handlers/callbacks/broadcast.handler'; import { BroadcastModule } from '../broadcast/broadcast.module'; import { AdminDiscountHandler } from './handlers/callbacks/admin-discount.handler'; import { WaitingAdminDiscountCodeHandler } from './handlers/messages/waiting-admin-discount-code.handler'; import { WaitingAdminDiscountValueHandler } from './handlers/messages/waiting-admin-discount-value.handler'; import { WaitingAdminDiscountMaxUsageHandler } from './handlers/messages/waiting-admin-discount-max-usage.handler'; import { WaitingAdminDiscountExpiryHandler } from './handlers/messages/waiting-admin-discount-expiry.handler'; import { HelpHandler } from './handlers/callbacks/help.handler'; import { BankCardModule } from '../bank-cards/bank-card.module'; import { PaymentRequestModule } from '../payment-requests/payment-request.module'; import { BankCardHandler } from './handlers/callbacks/bank-card.handler'; import { WaitingBankCardNumberHandler } from './handlers/messages/waiting-bank-card-number.handler'; import { WaitingBankCardHolderHandler } from './handlers/messages/waiting-bank-card-holder.handler'; import { PaymentApprovalHandler } from './handlers/callbacks/payment-approval.handler'; import { WaitingPaymentReceiptHandler } from './handlers/messages/waiting-payment-receipt.handler'; import { WaitingRejectReasonHandler } from './handlers/messages/waiting-reject-reason.handler'; import { ChannelBanModule } from '../channel-bans/channel-ban.module'; import { MyPaymentRequestsHandler } from './handlers/callbacks/my-payment-requests.handler'; import { WaitingAdminUserSearchQueryHandler } from './handlers/messages/waiting-admin-user-search-query.handler'; import { AdminOwnerBanHandler } from './handlers/callbacks/admin-owner-ban.handler'; import { OwnerMemberBanHandler } from './handlers/callbacks/owner-member-ban.handler'; import { WaitingBanDurationHandler } from './handlers/messages/waiting-ban-duration.handler'; import { WaitingBanReasonHandler } from './handlers/messages/waiting-ban-reason.handler'; import { AdminUserSearchHandler } from './handlers/callbacks/admin-user-search.handler'; import { PendingPaymentRequestsHandler } from './handlers/callbacks/pending-payment-requests.handler'; import { WaitingRedeemDiscountCodeHandler } from './handlers/messages/waiting-redeem-discount-code.handler'; import { ServiceOutageModule } from '../service-outage/service-outage.module'; import { OutageConfirmationHandler } from './handlers/callbacks/outage-confirmation.handler'; import { AdminUserListHandler } from './handlers/callbacks/admin-user-list.handler'; import { MandatoryJoinMiddleware } from './middlewares/mandatory-join.middleware'; import { JoinCheckHandler } from './handlers/callbacks/join-check.handler'; import { AdminOutageHandler } from './handlers/callbacks/admin-outage.handler'; import { WaitingOutageEndDateHandler } from './handlers/messages/waiting-outage-end-date.handler'; import { WaitingOutageStartDateHandler } from './handlers/messages/waiting-outage-start-date.handler'; @Module({ imports: [ DiscoveryModule, TelegramCoreModule, UsersModule, OwnerModule, ChannelModule, BotPlanModule, BotSubscriptionModule, PlanModule, SubscriptionModule, DiscountModule, BankCardModule, PaymentRequestModule, BroadcastModule, ChannelBanModule, StatsModule, ServiceOutageModule, ], providers: [ TelegramRegistry, OutageConfirmationHandler, PhotoDispatcher, AdminOutageHandler, WaitingOutageStartDateHandler, WaitingOutageEndDateHandler, AuthMiddleware, RoleMiddleware, MandatoryJoinMiddleware, SessionMiddleware, JoinCheckHandler, TelegramSessionRepository, TelegramSessionService, MessageDispatcher, StartHandler, AddChannelHandler, ChannelUpdateHandler, UserMenuHandler, ChannelMenuHandler, PlanMenuHandler, AdminUserListHandler, PlanDurationUnitHandler, WaitingPlanTitleHandler, WaitingPlanDurationHandler, WaitingPlanPriceHandler, WaitingPlanEditValueHandler, SubscriptionMenuHandler, JoinRequestHandler, AdminOutageHandler, AdminPanelHandler, AdminBotPlanDurationUnitHandler, WaitingBotPlanTitleHandler, WaitingBotPlanDurationHandler, WaitingBotPlanPriceHandler, BotSubscriptionHandler, NavigationHandler, PingHandler, NoopHandler, MemberMenuHandler, HelpHandler, DiscountMenuHandler, WaitingNewDiscountCodeHandler, WaitingNewDiscountValueHandler, PendingPaymentRequestsHandler, WaitingNewDiscountMaxUsageHandler, WaitingNewDiscountExpiryHandler, WaitingMemberSearchQueryHandler, StatsHandler, BroadcastHandler, WaitingBroadcastContentHandler, AdminDiscountHandler, WaitingRedeemDiscountCodeHandler, WaitingAdminDiscountCodeHandler, WaitingAdminDiscountValueHandler, WaitingAdminDiscountMaxUsageHandler, WaitingAdminDiscountExpiryHandler, BankCardHandler, WaitingBankCardNumberHandler, WaitingBankCardHolderHandler, PaymentApprovalHandler, WaitingPaymentReceiptHandler, WaitingRejectReasonHandler, AdminOwnerBanHandler, OwnerMemberBanHandler, WaitingBanDurationHandler, WaitingBanReasonHandler, AdminUserSearchHandler, WaitingAdminUserSearchQueryHandler, MyPaymentRequestsHandler, ], }) export class TelegramModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\telegram_core.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { TelegramService } from './services/telegram.service'; import { TelegramApiService } from './services/telegram-api.service'; @Module({ providers: [ TelegramService, TelegramApiService, { provide: 'BOT', useFactory: (telegramService: TelegramService) => telegramService.getBot(), inject: [TelegramService], }, ], exports: [TelegramService, TelegramApiService, 'BOT'], }) export class TelegramCoreModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\config\admin.config.ts ############################################################ . export function getAdminTelegramIds(): string[] { return (process.env.ADMIN_TELEGRAM_IDS ?? '') .split(',') .map((id) => id.trim()) .filter(Boolean); }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\constants\callback.ts ############################################################ . export const Callback = { NAVIGATION: { BACK: 'navigation:back' }, PAYMENT_REQUEST: { APPROVE: 'payreq:approve', REJECT: 'payreq:reject', MY_LIST: 'payreq:my-list', RESUME: 'payreq:resume', RECEIVER_LIST: 'payreq:receiver-list', RECEIVER_DETAIL: 'payreq:receiver-detail', }, HELP: { MENU: 'help:menu', }, BANK_CARD: { MENU: 'bankcard:menu', ADD: 'bankcard:add', LIST: 'bankcard:list', SET_DEFAULT: 'bankcard:set-default', DELETE: 'bankcard:delete', DELETE_CONFIRM: 'bankcard:delete-confirm', }, USER: { MENU: 'user:menu', CREATE_CHANNEL: 'user:create-channel', // WALLET: 'user:wallet', // WALLET_CHARGE: 'user:wallet-charge', }, BAN: { OWNER_LIST: 'ban:owner-list', OWNER_MENU: 'ban:owner-menu', OWNER_TEMP: 'ban:owner-temp', OWNER_PERMANENT: 'ban:owner-permanent', OWNER_UNBAN: 'ban:owner-unban', MEMBER_MENU: 'ban:member-menu', MEMBER_TEMP: 'ban:member-temp', MEMBER_PERMANENT: 'ban:member-permanent', MEMBER_UNBAN: 'ban:member-unban', MEMBER_LIST: 'ban:member-list', }, DISCOUNT: { MENU: 'discount:menu', CREATE: 'discount:create', SELECT_PLAN: 'discount:select-plan', SELECT_TYPE: 'discount:select-type', SKIP_MAX_USAGE: 'discount:skip-max-usage', SKIP_EXPIRY: 'discount:skip-expiry', CONFIRM_CREATE: 'discount:confirm-create', CANCEL_CREATE: 'discount:cancel-create', LIST: 'discount:list', DELETE: 'discount:delete', }, OWNER: { PANEL: 'owner:panel', STATS: 'owner:stats', STATS_REVENUE: 'owner:stats-revenue', }, CHANNEL: { MENU: 'channel:menu', ADD: 'channel:add', LIST: 'channel:list', DETAIL: 'channel:detail', DELETE: 'channel:delete', REFRESH: 'channel:refresh', GET_LINK: 'channel:get-link', DELETE_CONFIRM: 'channel:delete-confirm', }, WALLET: { MENU: 'wallet:menu', CHARGE: 'wallet:charge', HISTORY: 'wallet:history', }, PLAN: { MENU: 'plan:menu', CREATE: 'plan:create', LIST: 'plan:list', SELECT_CHANNEL: 'plan:select-channel', DURATION_UNIT: 'plan:duration-unit', EDIT: 'plan:edit', EDIT_FIELD: 'plan:edit-field', DELETE: 'plan:delete', DELETE_CONFIRM: 'plan:delete-confirm', CONFIRM_CREATE: 'plan:confirm-create', CANCEL_CREATE: 'plan:cancel-create', EDIT_CREATE_TITLE: 'plan:edit-create-title', EDIT_CREATE_DURATION: 'plan:edit-create-duration', EDIT_CREATE_PRICE: 'plan:edit-create-price', }, SUBSCRIPTION: { MENU: 'subscription:menu', BROWSE_CHANNELS: 'subscription:browse-channels', BROWSE_PLANS: 'subscription:browse-plans', SELECT_PLAN: 'subscription:select-plan', ENTER_DISCOUNT: 'subscription:enter-discount', SKIP_DISCOUNT: 'subscription:skip-discount', SELECT_GATEWAY: 'subscription:select-gateway', MY_SUBSCRIPTIONS: 'subscription:my-list', RENEW: 'subscription:renew', UPGRADE: 'subscription:upgrade', SELECT_UPGRADE_PLAN: 'subscription:select-upgrade-plan', GET_INVITE_LINK: 'subscription:get-invite-link', CANCEL_PAYMENT: 'subscription:cancel-payment', }, ADMIN: { PANEL: 'admin:panel', BOT_PLAN_MENU: 'admin:bot-plan-menu', BOT_PLAN_CREATE: 'admin:bot-plan-create', BOT_PLAN_LIST: 'admin:bot-plan-list', BOT_PLAN_DELETE: 'admin:bot-plan-delete', BOT_PLAN_DURATION_UNIT: 'admin:bot-plan-duration-unit', BOT_PLAN_CONFIRM_CREATE: 'admin:bot_plan_confirm_create', BOT_PLAN_CANCEL_CREATE: 'admin:bot_plan_cancel_create', BOT_PLAN_EDIT_CREATE_TITLE: 'admin:bot_plan_edit_create_title', BOT_PLAN_EDIT_CREATE_DURATION: 'admin:bot_plan_edit_create_duration', BOT_PLAN_EDIT_CREATE_PRICE: 'admin:bot_plan_edit_create_price', WALLET_CHARGE_MANUAL: 'admin:wallet-charge-manual', CHANNELS_OVERVIEW: 'admin:channels-overview', DISCOUNT_MENU: 'admin:discount-menu', DISCOUNT_CREATE: 'admin:discount-create', DISCOUNT_SELECT_SCOPE: 'admin:discount-select-scope', DISCOUNT_SELECT_TYPE: 'admin:discount-select-type', DISCOUNT_SKIP_MAX_USAGE: 'admin:discount-skip-max-usage', DISCOUNT_SKIP_EXPIRY: 'admin:discount-skip-expiry', DISCOUNT_CONFIRM_CREATE: 'admin:discount-confirm-create', DISCOUNT_CANCEL_CREATE: 'admin:discount-cancel-create', DISCOUNT_LIST: 'admin:discount-list', DISCOUNT_DELETE: 'admin:discount-delete', USER_SEARCH: 'admin:user-search', USER_LIST: 'admin:user-list', }, JOIN: { CHECK: 'join:check', }, BOT_SUBSCRIPTION: { MENU: 'bot-sub:menu', SELECT_PLAN: 'bot-sub:select-plan', SELECT_GATEWAY: 'bot-sub:select-gateway', ENTER_DISCOUNT: 'bot-sub:enter-discount', SKIP_DISCOUNT: 'bot-sub:skip-discount', }, MEMBER: { MENU: 'member:menu', CHANNEL: 'member:channel', ACTIVE: 'member:active', EXPIRED: 'member:expired', SEARCH: 'member:search', REMOVE: 'member:remove', }, BROADCAST: { MENU: 'broadcast:menu', SELECT_TARGET: 'broadcast:select-target', CONFIRM: 'broadcast:confirm', CANCEL: 'broadcast:cancel', }, OUTAGE: { CONFIRM: 'outage:confirm', REJECT: 'outage:reject', MANUAL_START: 'outage:manual-start', REPORT: 'outage:report', RESOLVE: 'outage:resolve', DISCARD: 'outage:discard', }, } as const;. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\constants\command.ts ############################################################ . export const TelegramCommands = { START: 'start', ADD_CHANNEL: 'addchannel', } as const; export type TelegramCommand = (typeof TelegramCommands)[keyof typeof TelegramCommands];. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\constants\messages.ts ############################################################ . export const TelegramMessages = { // ============================================================ // General // ============================================================ SOMETHING_WENT_WRONG: '❌ خطایی رخ داد. لطفاً دوباره تلاش کنید.', ACCESS_DENIED: '⛔ شما دسترسی انجام این عملیات را ندارید.', INVALID_PRICE: '❌ مبلغ وارد شده صحیح نیست.', INVALID_DURATION: '❌ مدت زمان وارد شده صحیح نیست.', // INVALID_WALLET_AMOUNT: // '❌ مبلغ وارد شده معتبر نیست.', NO_SEARCH_RESULTS: '❌ کسی با این مشخصات پیدا نشد. دوباره امتحان کن (آیدی عددی یا یوزرنیمش رو دقیق بفرست).', // // ============================================================ // // Wallet // // ============================================================ // ENTER_WALLET_CHARGE_TARGET_USER: // '👤 آیدی عددی تلگرام کاربر مقصد را وارد کنید:', // WALLET_CHARGE_TARGET_NOT_FOUND: // '❌ کاربری با این آیدی در سیستم یافت نشد. کاربر باید حداقل یک‌بار /start را در ربات زده باشد.', // ENTER_WALLET_CHARGE_TARGET_AMOUNT: // (name: string) => // `💰 مبلغ شارژ برای «${name}» را وارد کنید:`, // WALLET_CHARGE_MANUAL_SUCCESS: // (name: string, amount: number) => // `✅ کیف پول «${name}» با مبلغ ${amount.toLocaleString()} تومان شارژ شد.`, // WALLET_CHARGED_BY_ADMIN_NOTICE: // (amount: number) => // `💰 کیف پول شما توسط مدیریت به مبلغ ${amount.toLocaleString()} تومان شارژ شد.`, // WALLET_BALANCE: // (balance: number) => // `💰 موجودی کیف پول: ${balance.toLocaleString()} تومان`, // ENTER_WALLET_CHARGE_AMOUNT: // '💳 برای شارژ کیف پول، مبلغ موردنظر را بنویسید:', // WALLET_PAYMENT_SUCCESS: // '✅ پرداخت از کیف پول با موفقیت انجام شد.', // WALLET_INSUFFICIENT: // '❌ موجودی کیف پول کافی نیست.', // ============================================================ // Channel // ============================================================ NO_CHANNELS_IN_SYSTEM: '📺 هنوز کانالی در سیستم ثبت نشده است.', NO_SELLABLE_CHANNELS: '❌ فعلاً هیچ کانالی برای خرید آماده نیست. صاحبان کانال هنوز پلنی نساخته‌ن!', NO_CHANNELS_FOR_PLAN: '❌ ابتدا باید حداقل یک کانال ثبت کنید.', NO_CHANNELS_FOR_MEMBERS: '❌ هنوز هیچ کانالی ثبت نکردی، پس عضوی هم نداری که مدیریتش کنی! اول از «➕ افزودن کانال» شروع کن.', CHANNEL_REACTIVATED: '✅ کانال قبلاً حذف‌شده دوباره فعال شد.', CHANNEL_DELETED: '🗑 کانال با موفقیت حذف شد.', CHANNEL_DELETE_CONFIRM: (title: string) => `❗️ مطمئنی می‌خوای کانال «${title}» رو حذف کنی؟\nاین کار برگشت‌ناپذیره، پس دوباره فکر کن 🙂`, CHANNEL_REGISTERED_NEXT_STEPS: [ '🎉 کانال شما آماده‌ی فروش است! برای شروع:', '', '1️⃣ از «💳 مدیریت پلن‌ها» یک پلن اشتراک برای این کانال بسازید.', '2️⃣ از لیست کانال‌ها، دکمه «🔗 دریافت لینک خرید» را بزنید و آن را برای مخاطبانتان ارسال کنید.', '3️⃣ کاربران با خرید پلن، به‌صورت خودکار به کانال دعوت می‌شوند.', ].join('\n'), ENTER_CHANNEL_ID: [ '📢 بریم کانالت رو ثبت کنیم! فقط ۳ قدم ساده مونده:', '', '1️⃣ من رو به کانالت اضافه و Admin کن.', ' (Channel Settings → Administrators → Add Admin)', '', '2️⃣ حتماً دسترسی «Invite Users via Link» رو بهم بده،', ' وگرنه نمی‌تونم برای مشترک‌هات لینک عضویت اختصاصی بسازم.', '', '3️⃣ کانالت باید Private باشه (بدون یوزرنیم عمومی @).', ' مثلاً اگه کانالت الان @mychannel هست، از تنظیمات، یوزرنیمش رو پاک کن.', '', '✅ همین که این ۳ تا رو انجام بدی، خودم می‌فهمم و کانالت رو ثبت می‌کنم — نیازی نیست چیزی اینجا برام بفرستی.', ].join('\n'), // ============================================================ // Plans // ============================================================ ENTER_PLAN_TITLE: '📝 یه اسم برای این پلن انتخاب کن (مثلاً: «اشتراک یک‌ماهه» یا «پلن VIP طلایی»).', ENTER_PLAN_PRICE: '💰 قیمت این پلن چقدره؟ فقط عدد و به تومان بنویس (مثلاً برای ۵۰ هزار تومان بنویس: 50000).', ENTER_PLAN_DURATION: '⏳ این پلن چند واحد اعتبار داره؟ (مثلاً اگه واحد رو «ماه» انتخاب کردی و می‌خوای ۳ ماهه باشه، بنویس: 3)', PLAN_CREATED: '✅ آفرین! پلن جدیدت ساخته شد و آماده‌ی فروشه 🎉', PLAN_FLOW_EXPIRED: '⌛ اطلاعات ایجاد پلن منقضی شده است. لطفاً دوباره شروع کنید.', PLAN_TITLE_DUPLICATE: '⚠️ یه پلن دیگه با همین اسم قبلاً برای این کانال داری. یه اسم دیگه انتخاب کن.', PLAN_EDIT_SELECT_FIELD: '✏️ کدام مورد ویرایش شود؟', ENTER_NEW_PLAN_TITLE: '✏️ عنوان جدید پلن را وارد کنید.', ENTER_NEW_PLAN_PRICE: '💰 قیمت جدید پلن را وارد کنید.', ENTER_NEW_PLAN_DURATION: '⏳ مدت زمان جدید پلن را وارد کنید.', PLAN_UPDATED: '✅ پلن با موفقیت ویرایش شد.', PLAN_DELETE_CONFIRM: '❗️ آیا از حذف این پلن مطمئن هستید؟', PLAN_DELETED: '🗑 پلن با موفقیت حذف شد.', PLAN_NOT_FOUND: '❌ پلن موردنظر پیدا نشد.', NO_OTHER_PLANS_FOR_UPGRADE: '❌ پلن دیگری برای ارتقا در این کانال وجود ندارد.', // ============================================================ // Subscription // ============================================================ SUBSCRIPTION_NOT_ACTIVE: '⛔ این اشتراک فعال نیست.', NO_ACTIVE_SUBSCRIPTIONS: '📦 شما اشتراک فعالی ندارید.', // ============================================================ // Bot Subscription // ============================================================ BOT_SUBSCRIPTION_EXPIRED: [ '⛔ اشتراک استفاده از پنل ربات برای تو فعال نیست یا تموم شده.', 'برای این‌که بتونی از امکانات مدیریتی استفاده کنی (ثبت کانال، ساخت پلن و...)، باید اول این اشتراک رو از سازنده‌ی ربات بخری یا تمدید کنی.', ].join('\n'), // ============================================================ // Bot Plans // ============================================================ ENTER_BOT_PLAN_TITLE: '📝 اسم این پلن اشتراک ربات چی باشه؟ (مثلاً: «اشتراک یک‌ساله ویژه»)', ENTER_BOT_PLAN_DURATION: '⏳ عدد مدت زمان پلن را وارد کنید:', ENTER_BOT_PLAN_PRICE: '💰 قیمتش رو به تومان و فقط با عدد بنویس (مثلاً: 200000).', BOT_PLAN_CREATED: '✅ پلن اشتراک ربات ایجاد شد.', NO_BOT_PLANS: '📋 پلنی ثبت نشده است.', // ============================================================ // Members // ============================================================ NO_ACTIVE_MEMBERS: 'فعلاً هیچ عضو فعالی نداری. وقتی کسی پلنت رو بخره، اینجا نشونش می‌دم 🙂', NO_EXPIRED_MEMBERS: 'خوش ‌بختانه هیچ عضو منقضی‌شده‌ای نداری!', ENTER_MEMBER_SEARCH_QUERY: '🔍 دنبال کی می‌گردی؟ آیدی عددی تلگرام یا یوزرنیمش رو برام بفرست (مثلاً: 123456789 یا mohammad).', MEMBER_REMOVED: '✅ انجام شد، این عضو از کانال حذف شد.', // ============================================================ // Discounts // ============================================================ ASK_DISCOUNT_CODE: '🎟 کد تخفیف داری؟ اگه داری بزن «دارم»، وگرنه رد شو بریم مرحله‌ی بعد.', ENTER_DISCOUNT_CODE: '🎟 یه کد برای این تخفیف انتخاب کن (مثلاً: OFF20 یا EID1403).', INVALID_DISCOUNT_CODE: '❌ این کد تخفیف معتبر نیست یا منقضی شده. یه کد دیگه امتحان کن یا رد شو.', DISCOUNT_APPLIED: '✅ کد تخفیف اعمال شد.', SELECT_DISCOUNT_SCOPE: '🎯 این کد برای کدام مورد اعمال شود؟', NO_GLOBAL_DISCOUNT_CODES: '📋 کد سراسری‌ای ثبت نشده است.', NO_PLANS_FOR_DISCOUNT: '❌ ابتدا باید حداقل یک پلن ثبت کنید.', SELECT_PLAN_FOR_DISCOUNT: '💳 این کد تخفیف برای کدوم پلنت باشه؟', ENTER_NEW_DISCOUNT_CODE: '🎟 یه کد برای این تخفیف انتخاب کن (مثلاً: OFF20 یا EID1403).', DISCOUNT_CODE_DUPLICATE: '⚠️ این کد قبلاً استفاده شده، یه کد دیگه انتخاب کن (مثلاً به جای OFF20 بنویس OFF20B).', SELECT_DISCOUNT_TYPE: '🔢 نوع تخفیف را انتخاب کنید:', ENTER_DISCOUNT_VALUE: (type: 'PERCENTAGE' | 'FIXED') => type === 'PERCENTAGE' ? '📊 چند درصد تخفیف بدیم؟ عددی بین ۱ تا ۱۰۰ بنویس (مثلاً برای ۲۰٪ بنویس: 20)' : '💰 چقدر تومن تخفیف بدیم؟ فقط عدد بنویس (مثلاً برای ۱۰ هزار تومان بنویس: 10000).', INVALID_DISCOUNT_VALUE: '❌ مقدار وارد شده صحیح نیست.', ASK_DISCOUNT_MAX_USAGE: '🔢 سقف تعداد استفاده از این کد چقدر باشد؟ (اگر محدودیتی نیست، رد شوید)', ENTER_DISCOUNT_MAX_USAGE: 'عدد سقف استفاده را وارد کنید:', ASK_DISCOUNT_EXPIRY: '📅 چند روز دیگر این کد منقضی شود؟ (اگر انقضا ندارد، رد شوید)', ENTER_DISCOUNT_EXPIRY_DAYS: 'عدد روز را وارد کنید:', DISCOUNT_CODE_CREATED: '✅ کد تخفیفت ساخته شد و از همین حالا فعاله! 🎉', NO_DISCOUNT_CODES: '📋 کدی ثبت نشده است.', DISCOUNT_DEACTIVATED: '🗑 کد تخفیف غیرفعال شد.', // ============================================================ // Payments // ============================================================ PAYMENT_LINK_MESSAGE: (url: string) => `🔗 برای تکمیل پرداخت روی لینک زیر بزنید:\n${url}`, // ============================================================ // Broadcast // ============================================================ SELECT_BROADCAST_TARGET: '📢 این پیام رو برای کیا بفرستم؟', ENTER_BROADCAST_CONTENT: '✏️ متن پیامت رو بنویس، همینو عیناً برای مخاطبات می‌فرستم:', BROADCAST_CONFIRM: (target: string, preview: string) => `📋 بذار یه‌بار با هم چک کنیم:\n\n«${preview}»\n\n📍 برای: ${target}\n\nارسال بشه؟`, BROADCAST_QUEUED: '✅ باشه، تو صف ارسال گذاشتمش. تا چند لحظه‌ی دیگه برای همه می‌ره.', // ============================================================ // Statistics // ============================================================ STATS_HEADER: '📊 آمار کلی', // ============================================================ // Help // ============================================================ HELP_GUIDE_TEXT: [ '📖 سلام! بذار قدم‌به‌قدم بهت بگم چطور کارت رو راه بندازی 👇', '', '0️⃣ اول از همه: اشتراک ربات', 'قبل از هر کاری، باید یه پلن اشتراک ربات بخری (حتی اگه هنوز هیچ کانالی نداری). این هزینه جدا از فروش خودته و بابت استفاده از پنل مدیریتی منه.', 'از دکمه‌ی «💳 خرید/تمدید اشتراک ربات» مبلغ رو کارت‌به‌کارت واریز کن و عکس فیشت رو برام بفرست؛ به‌محض تایید سازنده‌ی ربات، پنلت باز می‌شه.', '', '1️⃣ حالا کانالت رو بهم بسپار', '• من رو به کانالت اضافه و Admin کن (Channel Settings → Administrators → Add Admin).', '• حتماً دسترسی «Invite Users via Link» رو بهم بده — وگرنه نمی‌تونم لینک عضویت اختصاصی بسازم.', '• کانالت باید Private باشه (بدون یوزرنیم @). اگه عمومیه، یوزرنیمش رو از تنظیمات پاک کن.', '• به‌محض این‌که این کارها رو انجام بدی، خودم کانالت رو می‌شناسم و ثبتش می‌کنم — نیازی نیست چیزی اینجا بفرستی.', '', '2️⃣ کانالت ثبت شد؟ بریم بفروشیمش!', '• از «💳 مدیریت پلن‌ها» حداقل یه پلن (مثلاً «یک ماهه، ۵۰ هزار تومان») برای کانالت بساز.', '• از لیست کانال‌ها، دکمه‌ی «🔗 دریافت لینک خرید» رو بزن و همون لینک رو برای مخاطبات بفرست.', '• هر کسی این لینک رو بزنه و پلن بخره، خودکار دعوتش می‌کنم به کانال.', '', '3️⃣ نکته‌ی مهم درباره‌ی پول', '• همه‌ی پرداخت‌ها (چه خرید کاربرا از تو، چه خرید اشتراک ربات از سازنده) کارت‌به‌کارت و با ارسال عکس فیشه — من خودم هیچ پولی نگه نمی‌دارم.', '• برای این‌که مشترکات بتونن بهت پول بریزن، حتماً از «💳 کارت‌های بانکی» یه کارت (ترجیحاً پیش‌فرض) ثبت کن؛ وگرنه فلوی خرید کانالت اصلاً شروع نمی‌شه.', '• هر فیشی که برات میاد رو از همون پیام (با دکمه‌ی ✅/❌) بررسی کن. اگه فیشی رو رد کنی، مشتری می‌تونه دوباره فیش جدید بفرسته.', ].join('\n'), USER_MENU_GREETING: '👤 این پنل توئه! از اینجا می‌تونی اشتراک کانال‌های VIP رو بخری یا خودت هم یه کانال ثبت کنی و بفروشیش.', OWNER_MENU_GREETING: '🏠 این پنل مدیریت کانالته! از اینجا کانال، پلن، اعضا و همه‌چیز رو کنترل می‌کنی.', } as const;. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\context\telegram.context.ts ############################################################ . import { Context } from 'grammy'; import { User, Owner } from '@prisma/client'; import { TelegramSession } from '../session/telegram-session.interface'; export class TelegramContext extends Context { currentUser?: User; currentOwner?: Owner; session?: TelegramSession; ownerChannelCount?: number; botSubscriptionActive?: boolean; get userId(): string | undefined { return this.currentUser?.id; } get isOwner(): boolean { return Boolean(this.currentOwner); } get role(): 'ADMIN' | 'OWNER' | 'USER' { if (this.isAdmin) return 'ADMIN'; // 🔧 طبق باگ ۱: اگه اشتراک ربات فعاله، حتی بدون کانال هم owner حساب می‌شه if (this.currentOwner && ((this.ownerChannelCount ?? 0) > 0 || this.botSubscriptionActive)) { return 'OWNER'; } return 'USER'; } get isAdmin(): boolean { return this.currentUser?.role === 'ADMIN'; } get messageText(): string | undefined { return this.message?.text?.trim(); } hasTextMessage(): boolean { return Boolean(this.message?.text); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\decorators\telegram-callback.decorator.ts ############################################################ . import { SetMetadata } from '@nestjs/common'; export const TELEGRAM_CALLBACK_METADATA = 'telegram:callback'; export type TelegramCallbackMatchType = 'exact' | 'startsWith'; export interface TelegramCallbackOptions { pattern: string; matchType?: TelegramCallbackMatchType; } export const OnTelegramCallback = (...patterns: (string | TelegramCallbackOptions)[]): ClassDecorator => SetMetadata( TELEGRAM_CALLBACK_METADATA, patterns.map((p): TelegramCallbackOptions => typeof p === 'string' ? { pattern: p, matchType: 'exact' } : p, ), );. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\decorators\telegram-command.decorator.ts ############################################################ . import { SetMetadata } from '@nestjs/common'; export const TELEGRAM_COMMAND_METADATA = 'telegram:command'; export const OnTelegramCommand = (command: string): ClassDecorator => SetMetadata(TELEGRAM_COMMAND_METADATA, command);. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\decorators\telegram-message-step.decorator.ts ############################################################ . import { SetMetadata } from '@nestjs/common'; import { TelegramSessionStep } from '@prisma/client'; export const TELEGRAM_MESSAGE_STEP_METADATA = 'telegram:message-step'; export const OnTelegramMessageStep = (step: TelegramSessionStep): ClassDecorator => SetMetadata(TELEGRAM_MESSAGE_STEP_METADATA, step);. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\decorators\telegram-photo-step.decorator.ts ############################################################ . // decorators/telegram-photo-step.decorator.ts import { SetMetadata } from '@nestjs/common'; import { TelegramSessionStep } from '@prisma/client'; export const TELEGRAM_PHOTO_STEP_METADATA = 'telegram:photo-step'; export const OnTelegramPhotoStep = (step: TelegramSessionStep): ClassDecorator => SetMetadata(TELEGRAM_PHOTO_STEP_METADATA, step);. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\dispatcher\handler.interface.ts ############################################################ . import { TelegramContext } from '../context/telegram.context'; export interface TelegramHandler { execute(ctx: TelegramContext): Promise; }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\dispatcher\message.dispatcher.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../context/telegram.context'; import { TelegramHandler } from './handler.interface'; import { TelegramSessionStep } from '@prisma/client'; @Injectable() export class MessageDispatcher { private readonly logger = new Logger(MessageDispatcher.name); private readonly handlers = new Map(); register(step: TelegramSessionStep, handler: TelegramHandler): void { this.handlers.set(step, handler); } async dispatch(ctx: TelegramContext): Promise { if (!ctx.session) { this.logger.debug('Message received without session'); return; } const step = ctx.session.step; const handler = this.handlers.get(step); if (!handler) { // یعنی کاربر یه پیام متنی فرستاده در حالی که هیچ فلوی // فعالی نداره (step === IDLE) — نادیده می‌گیریم. return; } await handler.execute(ctx); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\dispatcher\photo.dispatcher.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../context/telegram.context'; import { TelegramHandler } from './handler.interface'; import { TelegramSessionStep } from '@prisma/client'; @Injectable() export class PhotoDispatcher { private readonly logger = new Logger(PhotoDispatcher.name); private readonly handlers = new Map(); register(step: TelegramSessionStep, handler: TelegramHandler): void { this.handlers.set(step, handler); } async dispatch(ctx: TelegramContext): Promise { if (!ctx.session) return; const handler = this.handlers.get(ctx.session.step); if (!handler) return; // کاربر عکسی فرستاده بدون این‌که منتظرش باشیم — نادیده می‌گیریم await handler.execute(ctx); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\channel.update.ts ############################################################ . import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; import { ChannelService } from 'src/modules/channels/services/channel.service'; import { OwnerService } from 'src/modules/owners/services/owner.service'; import { TelegramSessionService } from '../services/telegram-session.service'; import { ChannelKeyboard } from '../keyboards/channel.keyboard'; import { TelegramService } from '../services/telegram.service'; import { TelegramMessages } from '../constants/messages'; @Injectable() export class ChannelUpdateHandler implements OnModuleInit { private readonly logger = new Logger(ChannelUpdateHandler.name); constructor( private readonly telegramService: TelegramService, private readonly channelService: ChannelService, private readonly ownerService: OwnerService, private readonly sessionService: TelegramSessionService, ) { } onModuleInit() { this.telegramService.getBot().on('my_chat_member', (ctx) => this.onMyChatMember(ctx)); } private async onMyChatMember(ctx: any) { try { const update = ctx.myChatMember; if (!update) return; const chat = update.chat; if (chat.type !== 'channel') return; const fromTelegramUser = update.from; const newStatus = update.new_chat_member.status; if (newStatus === 'left' || newStatus === 'kicked') { return this.handleBotRemoved(String(chat.id), chat.title); } // طبق سند بخش ۶ قدم ۲.۱: فقط وقتی ربات "ادمین" یک "کانال" شده برامون مهمه if (newStatus !== 'administrator') return; if (chat.type !== 'channel') return; // طبق قدم ۲.۲: فقط اگه کاربر دقیقاً منتظر همین مرحله بود، ادامه بده const session = await this.sessionService.findByTelegramUserId(String(fromTelegramUser.id)); if (!session || session.step !== 'WAITING_CHANNEL_ADMIN_CONFIRM') { return; } const owner = await this.ownerService.findByUserId(session.userId); if (!owner) { this.logger.error(`Owner not found for user ${session.userId} during channel registration`); return; } // 🔧 قبلاً هر رکورد پیداشده (چه فعال چه غیرفعال) رو "تکراری" حساب می‌کرد // و مانع ثبت مجدد می‌شد. الان فقط کانال فعال رو تکراری حساب می‌کنیم؛ // کانال غیرفعال (قبلاً حذف‌شده) باید از طریق createChannel دوباره فعال بشه. const existingChannel = await this.channelService.findByTelegramChannelId(String(chat.id)); if (existingChannel?.isActive) { await this.sessionService.resetStep(session.userId); await this.telegramService.getBot().api.sendMessage( fromTelegramUser.id, `⚠️ کانال «${chat.title}» قبلاً ثبت شده است.`, ); return; } const chatInfo = await this.telegramService.getBot().api.getChat(chat.id); const isPrivate = !chatInfo.username; // const hasJoinApproval = Boolean(chatInfo.join_by_request); // best-effort — طبق Bot API فقط برای بعضی چت‌ها برمی‌گرده // if (!isPrivate || !hasJoinApproval) { if (!isPrivate) { await this.sessionService.update(session.userId, { step: 'WAITING_CHANNEL_ADMIN_CONFIRM', data: { ...(session.data as any), channelId: String(chat.id), title: chat.title, channelUsername: chatInfo.username, }, }); await this.telegramService.getBot().api.sendMessage( fromTelegramUser.id, [ '❌ فقط چند قدم دیگه مونده:', '', '• کانالت رو Private کن.', '• گزینه‌ی «Approve New Subscribers» رو فعال کن (تا عضویت‌ها نیاز به تایید داشته باشن).', '• بعدش دکمه‌ی «بررسی مجدد» رو بزن تا دوباره چک کنم.', ].join('\n'), { reply_markup: ChannelKeyboard.recheckSettings() }, ); return; // ثبت نهایی انجام نمی‌شود تا تایید بعدی } // این متد الان هم ساخت و هم reactivate رو خودش مدیریت می‌کنه await this.channelService.createChannel({ telegramChannelId: String(chat.id), title: chat.title, username: chat.username ?? undefined, ownerId: owner.id, }); await this.sessionService.resetStep(session.userId); const guideMessageId = (session.data as any)?.channelGuideMessageId; if (guideMessageId) { await this.telegramService.getBot().api.deleteMessage(fromTelegramUser.id, guideMessageId).catch(() => undefined); } await this.telegramService.getBot().api.sendMessage( fromTelegramUser.id, `✅ ایول! کانال «${chat.title}» با موفقیت ثبت شد 🎉`, ); if ('username' in chatInfo && chatInfo.username) { await this.telegramService.getBot().api.sendMessage( fromTelegramUser.id, [ '⚠️ یه نکته‌ی مهم: این کانال عمومیه (یوزرنیم داره).', 'یعنی هرکسی می‌تونه مستقیم و بدون پرداخت از طریق یوزرنیم عضو کانالت بشه و کنترل اشتراک از دست من خارج می‌شه.', 'پیشنهاد می‌کنم:', '۱. از تنظیمات کانال (Administrators → Manage Join Requests) گزینه‌ی «تایید عضویت اعضای جدید» رو فعال کنی، یا', '۲. یوزرنیم کانال رو حذف کنی و کاملاً خصوصیش کنی.', ].join('\n'), ); } // 🆕 طبق باگ ۳: راهنمای مرحله بعد await this.telegramService.getBot().api.sendMessage( fromTelegramUser.id, TelegramMessages.CHANNEL_REGISTERED_NEXT_STEPS, ); // await this.sessionService.resetStep(session.userId); // await this.telegramService.getBot().api.sendMessage(fromTelegramUser.id, `✅ کانال «${chat.title}» با موفقیت ثبت شد.`); await this.telegramService.getBot().api.sendMessage( fromTelegramUser.id, '📺 کانال ثبت شد', { reply_markup: ChannelKeyboard.backToList() }, ); } catch (error) { this.logger.error('Failed to process my_chat_member update', error); } } private async handleBotRemoved(telegramChannelId: string, title: string): Promise { const channel = await this.channelService.findByTelegramIdWithOwner(telegramChannelId); if (!channel || !channel.isActive) return; // ثبت نشده یا از قبل غیرفعاله، کاری نداریم const ownerTelegramId = channel.owner.user.telegramId; // 🔧 قبلاً حذف فیزیکی بود؛ الان soft-delete — پلن‌ها/اشتراک‌های // تاریخی این کانال دست‌نخورده باقی می‌مونن. await this.channelService.deactivate(channel.id); await this.telegramService.getBot().api.sendMessage( Number(ownerTelegramId), `⚠️ من از کانال «${title}» حذف یا اخراج شدم!\nاطلاعات این کانال رو غیرفعال کردم. اگه می‌خوای دوباره استفاده کنی، باید من رو دوباره به کانال اضافه و Administrator کنی.`, ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\join-request.handler.ts ############################################################ . import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; import { TelegramApiService } from '../services/telegram-api.service'; import { SubscriptionService } from 'src/modules/subscriptions/services/subscription.service'; import { TelegramService } from '../services/telegram.service'; import { ChannelBanService } from 'src/modules/channel-bans/services/channel-ban.service'; @Injectable() export class JoinRequestHandler implements OnModuleInit { private readonly logger = new Logger(JoinRequestHandler.name); constructor( private readonly telegramApi: TelegramApiService, private readonly telegramService: TelegramService, private readonly subscriptionService: SubscriptionService, private readonly channelBanService: ChannelBanService, ) { } onModuleInit() { this.telegramService.getBot().on('chat_join_request', (ctx) => this.onJoinRequest(ctx)); } private async onJoinRequest(ctx: any): Promise { try { const request = ctx.chatJoinRequest; if (!request) return; const chatId = String(request.chat.id); const fromTelegramId = String(request.from.id); // 🔧 تبدیل به string const inviteLinkName = request.invite_link?.name; const isValid = await this.isValidSubscriptionRequest(inviteLinkName, chatId, fromTelegramId); if (isValid) { await this.telegramApi.approveChatJoinRequest(chatId, request.from.id); } else { await this.telegramApi.declineChatJoinRequest(chatId, request.from.id); } } catch (error) { // طبق سند بخش ۷ قدم ۳: خطای غیرمنتظره فقط لاگ می‌شود، ربات متوقف نمی‌شود this.logger.error('Failed to process chat_join_request', error); } } private async isValidSubscriptionRequest( inviteLinkName: string | undefined, chatId: string, fromTelegramId: string, ): Promise { if (!inviteLinkName || !inviteLinkName.startsWith('sub-')) return false; const subscriptionId = inviteLinkName.slice('sub-'.length); const subscription = await this.subscriptionService.findById(subscriptionId); // 🔧 findByIdWithRelations نداریم if (!subscription) return false; if (subscription.status !== 'ACTIVE') return false; if (subscription.channel.telegramChannelId !== chatId) return false; if (subscription.user.telegramId !== fromTelegramId) return false; if (subscription.expiresAt < new Date()) return false; // 🆕 حتی با اشتراک معتبر، اگه بعداً بن شده باشه، اجازه‌ی عضویت نده const isBanned = await this.channelBanService.isBanned(subscription.channelId, subscription.userId); if (isBanned) return false; return true; } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\admin-bot-plan-duration-unit.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; @OnTelegramCallback({ pattern: Callback.ADMIN.BOT_PLAN_DURATION_UNIT, matchType: 'startsWith' }) @Injectable() export class AdminBotPlanDurationUnitHandler implements TelegramHandler { private readonly logger = new Logger(AdminBotPlanDurationUnitHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.session || !ctx.isAdmin) return; try { const unit = data.split(':')[2] as 'DAY' | 'MONTH' | 'YEAR'; await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BOT_PLAN_DURATION_VALUE', data: { ...ctx.session.data, durationUnit: unit }, }); await ctx.editMessageText(TelegramMessages.ENTER_BOT_PLAN_DURATION); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\admin-discount.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { DiscountService } from 'src/modules/discounts/services/discount.service'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; @OnTelegramCallback( { pattern: Callback.ADMIN.DISCOUNT_MENU, matchType: 'startsWith' }, // 🔧 Callback.ADMIN.DISCOUNT_CREATE, { pattern: Callback.ADMIN.DISCOUNT_SELECT_SCOPE, matchType: 'startsWith' }, { pattern: Callback.ADMIN.DISCOUNT_SELECT_TYPE, matchType: 'startsWith' }, Callback.ADMIN.DISCOUNT_SKIP_MAX_USAGE, Callback.ADMIN.DISCOUNT_SKIP_EXPIRY, Callback.ADMIN.DISCOUNT_CONFIRM_CREATE, Callback.ADMIN.DISCOUNT_CANCEL_CREATE, Callback.ADMIN.DISCOUNT_LIST, { pattern: Callback.ADMIN.DISCOUNT_DELETE, matchType: 'startsWith' }, ) @Injectable() export class AdminDiscountHandler implements TelegramHandler { private readonly logger = new Logger(AdminDiscountHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly discountService: DiscountService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.isAdmin) return; try { if (data.startsWith(Callback.ADMIN.DISCOUNT_SELECT_SCOPE)) return this.selectScope(ctx, data); if (data.startsWith(Callback.ADMIN.DISCOUNT_SELECT_TYPE)) return this.selectType(ctx, data); if (data.startsWith(Callback.ADMIN.DISCOUNT_DELETE)) return this.deleteCode(ctx, data); if (data.startsWith(Callback.ADMIN.DISCOUNT_MENU)) { const page = Number(data.split(':')[2]) || 0; return this.showList(ctx, page); } switch (data) { case Callback.ADMIN.DISCOUNT_CREATE: await ctx.editMessageText(TelegramMessages.SELECT_DISCOUNT_SCOPE, { reply_markup: AdminKeyboard.discountSelectScope(), }); return; case Callback.ADMIN.DISCOUNT_SKIP_MAX_USAGE: await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_ADMIN_DISCOUNT_EXPIRY_DAYS', data: ctx.session!.data, }); await ctx.editMessageText(TelegramMessages.ASK_DISCOUNT_EXPIRY, { reply_markup: AdminKeyboard.discountSkipExpiry(), }); return; case Callback.ADMIN.DISCOUNT_SKIP_EXPIRY: return this.showSummary(ctx); case Callback.ADMIN.DISCOUNT_CONFIRM_CREATE: return this.finalizeCreate(ctx); case Callback.ADMIN.DISCOUNT_CANCEL_CREATE: await this.sessionService.resetStep(ctx.currentUser.id); return this.showList(ctx, 0); } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async selectScope(ctx: TelegramContext, data: string): Promise { const scope = data.split(':')[2] as 'CHANNEL_PLAN' | 'BOT_PLAN'; await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_ADMIN_DISCOUNT_CODE', data: { newDiscountScope: scope }, }); await ctx.editMessageText(TelegramMessages.ENTER_NEW_DISCOUNT_CODE); } private async selectType(ctx: TelegramContext, data: string): Promise { const type = data.split(':')[2] as 'PERCENTAGE' | 'FIXED'; await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_ADMIN_DISCOUNT_VALUE', data: { ...ctx.session!.data, newDiscountType: type }, }); await ctx.editMessageText(TelegramMessages.ENTER_DISCOUNT_VALUE(type)); } private async showSummary(ctx: TelegramContext): Promise { const { newDiscountCode, newDiscountScope, newDiscountType, newDiscountValue, newDiscountMaxUsage, newDiscountExpiresInDays } = ctx.session!.data; const scopeFa = newDiscountScope === 'CHANNEL_PLAN' ? 'خرید اشتراک کانال (همه کانال‌ها)' : 'خرید اشتراک ربات'; const typeFa = newDiscountType === 'PERCENTAGE' ? 'درصدی' : 'مبلغ ثابت'; const valueFa = newDiscountType === 'PERCENTAGE' ? `${newDiscountValue}٪` : `${newDiscountValue?.toLocaleString()} تومان`; await ctx.editMessageText( [ '📋 خلاصه کد تخفیف سراسری:', `کد: ${newDiscountCode}`, `کاربرد: ${scopeFa}`, `نوع: ${typeFa}`, `مقدار: ${valueFa}`, `سقف استفاده: ${newDiscountMaxUsage ? newDiscountMaxUsage : 'نامحدود'}`, `انقضا: ${newDiscountExpiresInDays ? `${newDiscountExpiresInDays} روز دیگر` : 'ندارد'}`, '', 'آیا تایید می‌کنید؟', ].join('\n'), { reply_markup: AdminKeyboard.discountConfirm() }, ); } private async finalizeCreate(ctx: TelegramContext): Promise { const { newDiscountCode, newDiscountScope, newDiscountType, newDiscountValue, newDiscountMaxUsage, newDiscountExpiresInDays } = ctx.session!.data; if (!newDiscountCode || !newDiscountScope || !newDiscountType || !newDiscountValue) { await ctx.editMessageText(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser!.id); await this.showList(ctx, 0, true); return; } const expiresAt = newDiscountExpiresInDays ? new Date(Date.now() + newDiscountExpiresInDays * 24 * 60 * 60 * 1000) : undefined; await this.discountService.createGlobal({ code: newDiscountCode, type: newDiscountType, value: newDiscountValue, scope: newDiscountScope, maxUsage: newDiscountMaxUsage, expiresAt, }); await this.sessionService.resetStep(ctx.currentUser!.id); await ctx.editMessageText(TelegramMessages.DISCOUNT_CODE_CREATED); await this.showList(ctx, 0, true); } private async showList(ctx: TelegramContext, page: number, asNewMessage = false): Promise { const pageSize = 10; const [codes, total] = await Promise.all([ this.discountService.listGlobalPaginated(page, pageSize), this.discountService.countGlobal(), ]); const text = codes.length ? '🎟 کدهای تخفیف سراسری:' : TelegramMessages.NO_GLOBAL_DISCOUNT_CODES; const hasNextPage = (page + 1) * pageSize < total; const keyboard = AdminKeyboard.discountList(codes, page, hasNextPage); if (asNewMessage) { await ctx.reply(text, { reply_markup: keyboard }); } else { await ctx.editMessageText(text, { reply_markup: keyboard }); } } private async deleteCode(ctx: TelegramContext, data: string): Promise { const id = data.split(':')[2]; const ok = await this.discountService.deactivateGlobal(id); if (!ok) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await ctx.answerCallbackQuery({ text: TelegramMessages.DISCOUNT_DEACTIVATED }); await this.showList(ctx, 0); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\admin-outage.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { InlineKeyboard } from 'grammy'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { ServiceOutageService } from 'src/modules/service-outage/services/service-outage.service'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; import { formatPersianDate } from 'src/common/utils/date.util'; @OnTelegramCallback( Callback.OUTAGE.MANUAL_START, Callback.OUTAGE.REPORT, Callback.OUTAGE.RESOLVE, Callback.OUTAGE.DISCARD, ) @Injectable() export class AdminOutageHandler implements TelegramHandler { private readonly logger = new Logger(AdminOutageHandler.name); constructor( private readonly outageService: ServiceOutageService, private readonly sessionService: TelegramSessionService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.isAdmin) return; // 🔒 گارد مستقل try { if (data === Callback.OUTAGE.MANUAL_START) return this.startManualFlow(ctx); if (data === Callback.OUTAGE.REPORT) return this.showCurrentPending(ctx); if (data === Callback.OUTAGE.RESOLVE) return this.resolve(ctx); if (data === Callback.OUTAGE.DISCARD) return this.discard(ctx); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async startManualFlow(ctx: TelegramContext): Promise { const pending = await this.outageService.findPendingApply(); if (pending) { await ctx.answerCallbackQuery({ text: '⚠️ یک قطعی ثبت‌شده منتظر اعمال از قبل هست.', show_alert: true }); await this.showReportFor(ctx, pending.id); return; } await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_OUTAGE_START_DATE', data: {}, }); await ctx.editMessageText( '📅 تاریخ و ساعت شروع قطعی رو به شمسی بنویس.\nمثال: 1403/05/12 یا با ساعت: 1403/05/12 14:30', ); } private async showCurrentPending(ctx: TelegramContext): Promise { const pending = await this.outageService.findPendingApply(); if (!pending) { await ctx.editMessageText('✅ در حال حاضر قطعی ثبت‌شده‌ی منتظر اعمالی وجود ندارد.', { reply_markup: new InlineKeyboard() .text('⚠️ ثبت قطعی جدید', Callback.OUTAGE.MANUAL_START) .row() .text('⬅️ بازگشت', Callback.ADMIN.PANEL), }); return; } await this.showReportFor(ctx, pending.id); } // 🆕 public تا از WaitingOutageEndDateHandler هم صدا زده بشه async showReportFor(ctx: TelegramContext, outageId: string): Promise { const data = await this.outageService.getReportForOutage(outageId); if (!data || !data.outage.reconnectedAt) return; const subLines = data.subs.slice(0, 30).map((s) => `📺 ${s.channel.title} | 👤 ${s.user.firstName ?? s.user.telegramId} | تا ${formatPersianDate(s.expiresAt)}`, ); const botSubLines = data.botSubs.slice(0, 30).map((b) => `🤖 ${b.owner.displayName ?? b.owner.user.telegramId} | تا ${formatPersianDate(b.expiresAt)}`, ); const durationHours = Math.round( (data.outage.reconnectedAt.getTime() - data.outage.detectedAt.getTime()) / (60 * 60 * 1000), ); const text = [ '⚠️ گزارش قطعی دستی', `شروع: ${formatPersianDate(data.outage.detectedAt)}`, `پایان: ${formatPersianDate(data.outage.reconnectedAt)}`, `مدت: حدود ${durationHours} ساعت`, '', `📺 اشتراک‌های کانال تحت‌تاثیر (${data.subs.length}):`, subLines.length ? subLines.join('\n') : ' موردی نیست', data.subs.length > 30 ? ` … و ${data.subs.length - 30} مورد دیگر` : '', '', `🤖 اشتراک‌های ربات تحت‌تاثیر (${data.botSubs.length}):`, botSubLines.length ? botSubLines.join('\n') : ' موردی نیست', data.botSubs.length > 30 ? ` … و ${data.botSubs.length - 30} مورد دیگر` : '', '', 'با تایید، مدت قطعی به اعتبار همه‌ی موارد بالا اضافه می‌شود.', ].filter(Boolean).join('\n'); const keyboard = new InlineKeyboard() .text('✅ تایید و اعمال اصلاح', Callback.OUTAGE.RESOLVE) .text('❌ انصراف (حذف)', Callback.OUTAGE.DISCARD) .row() .text('⬅️ بازگشت', Callback.ADMIN.PANEL); if (ctx.callbackQuery) { await ctx.editMessageText(text, { reply_markup: keyboard }).catch(() => ctx.reply(text, { reply_markup: keyboard })); } else { await ctx.reply(text, { reply_markup: keyboard }); // 🔑 چون از یک Message Handler هم صدا زده می‌شه } } private async resolve(ctx: TelegramContext): Promise { const pending = await this.outageService.findPendingApply(); if (!pending) { await ctx.answerCallbackQuery({ text: 'قطعی در انتظاری وجود ندارد.', show_alert: true }); return; } await this.outageService.applyOutage(pending.id); await ctx.answerCallbackQuery({ text: '✅ اصلاح اعمال شد' }); await ctx.editMessageText('✅ قطعی اعمال شد و اعتبار اشتراک‌های متاثر اصلاح شد.', { reply_markup: AdminKeyboard.menu(), }); } private async discard(ctx: TelegramContext): Promise { const pending = await this.outageService.findPendingApply(); if (!pending) { await ctx.answerCallbackQuery({ text: 'موردی برای انصراف نیست.', show_alert: true }); return; } await this.outageService.discard(pending.id); await ctx.answerCallbackQuery({ text: '❌ رد شد و حذف گردید' }); await ctx.editMessageText('❌ قطعی ثبت‌شده حذف شد، هیچ اصلاحی اعمال نشد.', { reply_markup: AdminKeyboard.menu(), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\admin-owner-ban.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { OwnerService } from 'src/modules/owners/services/owner.service'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { TelegramApiService } from '../../services/telegram-api.service'; import { BanKeyboard } from '../../keyboards/ban.keyboard'; import { formatPersianDate } from 'src/common/utils/date.util'; @OnTelegramCallback( { pattern: Callback.BAN.OWNER_LIST, matchType: 'startsWith' }, { pattern: Callback.BAN.OWNER_MENU, matchType: 'startsWith' }, { pattern: Callback.BAN.OWNER_TEMP, matchType: 'startsWith' }, { pattern: Callback.BAN.OWNER_PERMANENT, matchType: 'startsWith' }, { pattern: Callback.BAN.OWNER_UNBAN, matchType: 'startsWith' }, ) @Injectable() export class AdminOwnerBanHandler implements TelegramHandler { private readonly logger = new Logger(AdminOwnerBanHandler.name); constructor( private readonly ownerService: OwnerService, private readonly sessionService: TelegramSessionService, private readonly telegramApiService: TelegramApiService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.isAdmin) return; // 🔒 گارد مستقل try { if (data.startsWith(Callback.BAN.OWNER_LIST)) return this.showList(ctx, data); if (data.startsWith(Callback.BAN.OWNER_MENU)) return this.showActions(ctx, data); if (data.startsWith(Callback.BAN.OWNER_TEMP)) return this.askDuration(ctx, data); if (data.startsWith(Callback.BAN.OWNER_PERMANENT)) return this.askReasonForPermanent(ctx, data); if (data.startsWith(Callback.BAN.OWNER_UNBAN)) return this.unban(ctx, data); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showList(ctx: TelegramContext, data: string): Promise { const page = Number(data.split(':')[2]) || 0; const pageSize = 20; const [owners, total] = await Promise.all([ this.ownerService.findAllPaginated(page, pageSize), this.ownerService.countAll(), ]); if (!owners.length) { await ctx.editMessageText('📋 صاحب کانالی ثبت نشده است.'); return; } const hasNextPage = (page + 1) * pageSize < total; await ctx.editMessageText(`👥 لیست صاحبان کانال (صفحه ${page + 1}):`, { reply_markup: BanKeyboard.ownersListNav(owners, page, hasNextPage), }); } private async showActions(ctx: TelegramContext, data: string): Promise { const ownerId = data.split(':')[2]; const owner = await this.ownerService.findByIdWithUser(ownerId); if (!owner) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } const isBanned = owner.banStatus !== 'NONE'; const statusText = isBanned ? `⛔ وضعیت: بن‌شده\nدلیل: ${owner.banReason ?? '—'}\nتا: ${owner.bannedUntil ? formatPersianDate(owner.bannedUntil) : 'دائم'}` : '✅ وضعیت: فعال'; await ctx.editMessageText( [ `👤 ${owner.displayName}`, `آیدی: ${owner.user.telegramId}`, statusText, ].join('\n'), { reply_markup: BanKeyboard.ownerActions(ownerId, isBanned) }, ); } private async askDuration(ctx: TelegramContext, data: string): Promise { const ownerId = data.split(':')[2]; await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_BAN_DURATION_DAYS', data: { banTargetType: 'OWNER', banTargetId: ownerId }, }); await ctx.editMessageText('⏳ چند روز این صاحب کانال رو محدود کنیم؟ فقط عدد بنویس:',); } private async askReasonForPermanent(ctx: TelegramContext, data: string): Promise { const ownerId = data.split(':')[2]; await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_BAN_REASON', data: { banTargetType: 'OWNER', banTargetId: ownerId }, // بدون banDurationDays یعنی دائم }); await ctx.editMessageText('📝 دلیل محدودیت دائمش چیه؟'); } private async unban(ctx: TelegramContext, data: string): Promise { const ownerId = data.split(':')[2]; const owner = await this.ownerService.unban(ownerId); await ctx.answerCallbackQuery({ text: '✅ بن برداشته شد' }); const ownerWithUser = await this.ownerService.findByIdWithUser(ownerId); if (ownerWithUser) { await this.telegramApiService.sendMessage( ownerWithUser.user.telegramId, '✅ محدودیت حساب شما برداشته شد. خوش برگشتید! 👋', ).catch(() => undefined); } await this.showActions(ctx, `${Callback.BAN.OWNER_MENU}:${ownerId}`); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\admin-panel.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { BotPlanService } from 'src/modules/bot-plans/services/bot-plan.service'; import { ChannelService } from 'src/modules/channels/services/channel.service'; @OnTelegramCallback( Callback.ADMIN.PANEL, Callback.ADMIN.BOT_PLAN_MENU, Callback.ADMIN.BOT_PLAN_CREATE, Callback.ADMIN.BOT_PLAN_LIST, { pattern: Callback.ADMIN.CHANNELS_OVERVIEW, matchType: 'startsWith' }, // 🔧 تغییر از exact به startsWith Callback.ADMIN.BOT_PLAN_CONFIRM_CREATE, Callback.ADMIN.BOT_PLAN_CANCEL_CREATE, Callback.ADMIN.BOT_PLAN_EDIT_CREATE_TITLE, Callback.ADMIN.BOT_PLAN_EDIT_CREATE_DURATION, Callback.ADMIN.BOT_PLAN_EDIT_CREATE_PRICE, { pattern: Callback.ADMIN.BOT_PLAN_DELETE, matchType: 'startsWith', }, ) @Injectable() export class AdminPanelHandler implements TelegramHandler { private readonly logger = new Logger(AdminPanelHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly botPlanService: BotPlanService, private readonly channelService: ChannelService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.isAdmin) return; // 🔒 گارد مستقل try { if (data.startsWith(Callback.ADMIN.BOT_PLAN_DELETE)) { return this.deletePlan(ctx, data); } if (data.startsWith(Callback.ADMIN.CHANNELS_OVERVIEW)) { // 🔧 تغییر از === به startsWith return this.showChannelsOverview(ctx, data); } switch (data) { case Callback.ADMIN.PANEL: await ctx.editMessageText('👑 پنل سازنده ربات', { reply_markup: AdminKeyboard.menu() }); return; case Callback.ADMIN.BOT_PLAN_MENU: await ctx.editMessageText('💳 پلن‌های اشتراک ربات', { reply_markup: AdminKeyboard.botPlanMenu() }); return; case Callback.ADMIN.BOT_PLAN_CREATE: await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BOT_PLAN_TITLE', data: {}, }); await ctx.editMessageText(TelegramMessages.ENTER_BOT_PLAN_TITLE); return; case Callback.ADMIN.BOT_PLAN_LIST: return this.listPlans(ctx); case Callback.ADMIN.BOT_PLAN_CONFIRM_CREATE: return this.finalizeBotPlanCreate(ctx); case Callback.ADMIN.CHANNELS_OVERVIEW: return this.showChannelsOverview(ctx, data); case Callback.ADMIN.BOT_PLAN_CANCEL_CREATE: await this.sessionService.resetStep(ctx.currentUser.id); await ctx.editMessageText( '💳 پلن‌های اشتراک ربات', { reply_markup: AdminKeyboard.botPlanMenu(), }, ); return; case Callback.ADMIN.BOT_PLAN_EDIT_CREATE_TITLE: await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BOT_PLAN_TITLE', data: ctx.session!.data, }); await ctx.editMessageText( TelegramMessages.ENTER_BOT_PLAN_TITLE, ); return; case Callback.ADMIN.BOT_PLAN_EDIT_CREATE_DURATION: await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BOT_PLAN_DURATION_UNIT', data: ctx.session!.data, }); await ctx.editMessageText( '⏳ واحد زمان را انتخاب کنید:', { reply_markup: AdminKeyboard.durationUnits(), }, ); return; case Callback.ADMIN.BOT_PLAN_EDIT_CREATE_PRICE: await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BOT_PLAN_PRICE', data: ctx.session!.data, }); await ctx.editMessageText( TelegramMessages.ENTER_BOT_PLAN_PRICE, ); return; } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async finalizeBotPlanCreate(ctx: TelegramContext): Promise { const { title, price, durationUnit, durationValue, } = ctx.session!.data; if (!title || !price || !durationUnit || !durationValue) { await ctx.editMessageText( TelegramMessages.PLAN_FLOW_EXPIRED, ); await this.sessionService.resetStep( ctx.currentUser!.id, ); return; } try { await this.botPlanService.create({ title, price, durationUnit, durationValue, }); await this.sessionService.resetStep( ctx.currentUser!.id, ); await ctx.editMessageText( TelegramMessages.BOT_PLAN_CREATED, ); await ctx.reply( '💳 پلن‌های اشتراک ربات', { reply_markup: AdminKeyboard.botPlanMenu(), }, ); } catch (error) { this.logger.error( 'Failed to create bot plan', error, ); await ctx.editMessageText( TelegramMessages.SOMETHING_WENT_WRONG, ); } } private async listPlans(ctx: TelegramContext): Promise { const plans = await this.botPlanService.findActive(); if (!plans.length) { await ctx.editMessageText(TelegramMessages.NO_BOT_PLANS, { reply_markup: AdminKeyboard.botPlanMenu() }); return; } const mapped = plans.map((p) => ({ id: p.id, title: p.title, price: Number(p.price) })); await ctx.editMessageText('📋 پلن‌های فعال (لمس = غیرفعال‌سازی):', { reply_markup: AdminKeyboard.planList(mapped), }); } private async deletePlan(ctx: TelegramContext, data: string): Promise { const id = data.split(':')[2]; await this.botPlanService.deactivate(id); // حذف نرم — طبق قانون بخش ۱۷ سند await this.listPlans(ctx); } private async showChannelsOverview(ctx: TelegramContext, data: string): Promise { const page = Number(data.split(':')[2]) || 0; const pageSize = 20; const [channels, total] = await Promise.all([ this.channelService.listAllWithOwners(page, pageSize), this.channelService.countAll(), ]); console.log(page); if (!channels.length) { await ctx.reply(TelegramMessages.NO_CHANNELS_IN_SYSTEM); await ctx.reply('👑 پنل سازنده ربات', { reply_markup: AdminKeyboard.menu() }); return; } const text = channels .map((c) => { const status = c.isActive ? '✅' : '🗑'; return `${status} ${c.title} — مالک: ${c.owner.displayName} (@${c.owner.user.username ?? c.owner.user.telegramId})`; }) .join('\n'); const hasNextPage = (page + 1) * pageSize < total; await ctx.reply(`${text}\n\nصفحه ${page + 1}`, { reply_markup: AdminKeyboard.channelsOverviewNav(page, hasNextPage), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\admin-user-list.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { InlineKeyboard } from 'grammy'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { UsersService } from 'src/modules/users/services/users.service'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; @OnTelegramCallback({ pattern: Callback.ADMIN.USER_LIST, matchType: 'startsWith' }) @Injectable() export class AdminUserListHandler implements TelegramHandler { private readonly logger = new Logger(AdminUserListHandler.name); constructor(private readonly usersService: UsersService) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.isAdmin) return; // 🔒 گارد مستقل try { const page = Number(data.split(':')[2]) || 0; const pageSize = 20; const [users, total] = await Promise.all([ this.usersService.findAllPaginated(page, pageSize), this.usersService.countAll(), ]); if (!users.length) { await ctx.editMessageText('کاربری ثبت نشده است.'); return; } const roleFa = { USER: 'کاربر', ADMIN: 'ادمین' }; const text = users .map((u) => { const fullName = [u.firstName, u.lastName].filter(Boolean).join(' ') || '—'; return `👤 ${fullName} | @${u.username ?? '—'} | ${u.telegramId} | ${roleFa[u.role]}`; }) .join('\n'); const hasNextPage = (page + 1) * pageSize < total; const keyboard = new InlineKeyboard(); if (page > 0) keyboard.text('⬅️ قبلی', `${Callback.ADMIN.USER_LIST}:${page - 1}`); if (hasNextPage) keyboard.text('➡️ بعدی', `${Callback.ADMIN.USER_LIST}:${page + 1}`); if (page > 0 || hasNextPage) keyboard.row(); keyboard.text('🔍 جستجو', Callback.ADMIN.USER_SEARCH).row(); keyboard.text(KeyboardConstants.BACK, Callback.ADMIN.PANEL); await ctx.editMessageText(`👥 کاربران (صفحه ${page + 1}):\n\n${text}`, { reply_markup: keyboard }); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\admin-user-search.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramSessionService } from '../../services/telegram-session.service'; @OnTelegramCallback(Callback.ADMIN.USER_SEARCH) @Injectable() export class AdminUserSearchHandler implements TelegramHandler { private readonly logger = new Logger(AdminUserSearchHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.isAdmin) return; // 🔒 await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_ADMIN_USER_SEARCH_QUERY', data: {}, }); await ctx.editMessageText('🔍 آیدی عددی، یوزرنیم یا نام کاربر را وارد کنید:'); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\bank-card.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { BankCardService } from 'src/modules/bank-cards/services/bank-card.service'; import { BankCardKeyboard } from '../../keyboards/bank-card.keyboard'; @OnTelegramCallback( { pattern: Callback.BANK_CARD.MENU, matchType: 'startsWith' }, Callback.BANK_CARD.ADD, { pattern: Callback.BANK_CARD.SET_DEFAULT, matchType: 'startsWith' }, { pattern: Callback.BANK_CARD.DELETE_CONFIRM, matchType: 'startsWith' }, { pattern: Callback.BANK_CARD.DELETE, matchType: 'startsWith' }, ) @Injectable() export class BankCardHandler implements TelegramHandler { private readonly logger = new Logger(BankCardHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly bankCardService: BankCardService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser) return; try { if (data.startsWith(Callback.BANK_CARD.SET_DEFAULT)) return this.setDefault(ctx, data); if (data.startsWith(Callback.BANK_CARD.DELETE_CONFIRM)) return this.confirmDelete(ctx, data); if (data.startsWith(Callback.BANK_CARD.DELETE)) return this.askDelete(ctx, data); if (data.startsWith(Callback.BANK_CARD.MENU)) { const page = Number(data.split(':')[2]) || 0; return this.showList(ctx, page); } if (data === Callback.BANK_CARD.ADD) { await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BANK_CARD_NUMBER', data: {} }); await ctx.editMessageText('💳 شماره کارتت رو برام بفرست (۱۶ رقم، بدون فاصله. مثلاً: 6037991234567890)'); return; } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showList(ctx: TelegramContext, page: number, asNewMessage = false): Promise { const pageSize = 10; const [cards, total] = ctx.isAdmin ? await Promise.all([ this.bankCardService.listForAdminPaginated(ctx.currentUser!.id, page, pageSize), this.bankCardService.countForAdmin(ctx.currentUser!.id), ]) : await Promise.all([ this.bankCardService.listForOwnerPaginated(ctx.currentOwner!.id, page, pageSize), this.bankCardService.countForOwner(ctx.currentOwner!.id), ]); const text = cards.length ? '💳 کارت‌های بانکی شما:' : '📋 هنوز کارتی ثبت نکرده‌اید.'; const hasNextPage = (page + 1) * pageSize < total; const keyboard = BankCardKeyboard.list(cards, page, hasNextPage); if (asNewMessage) { await ctx.reply(text, { reply_markup: keyboard }); } else { await ctx.editMessageText(text, { reply_markup: keyboard }); } } private async setDefault(ctx: TelegramContext, data: string): Promise { const cardId = data.split(':')[2]; if (ctx.isAdmin) { await this.bankCardService.setDefaultForAdmin(ctx.currentUser!.id, cardId); } else { await this.bankCardService.setDefaultForOwner(ctx.currentOwner!.id, cardId); } await ctx.answerCallbackQuery({ text: '✅ کارت پیش‌فرض تغییر کرد' }); await this.showList(ctx, 0); } private async askDelete(ctx: TelegramContext, data: string): Promise { const cardId = data.split(':')[2]; await ctx.editMessageText( '❗️ مطمئنی می‌خوای این کارت رو حذف کنی؟', { reply_markup: BankCardKeyboard.deleteConfirm(cardId) }, ); } private async confirmDelete(ctx: TelegramContext, data: string): Promise { const cardId = data.split(':')[2]; await this.bankCardService.deactivate(cardId); await ctx.answerCallbackQuery({ text: '🗑 کارت حذف شد' }); await this.showList(ctx, 0); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\bot-subscription.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { BotSubscriptionKeyboard } from '../../keyboards/bot-subscription.keyboard'; import { BotPlanService } from 'src/modules/bot-plans/services/bot-plan.service'; import { PaymentRequestService } from 'src/modules/payment-requests/services/payment-request.service'; import { BankCardService } from 'src/modules/bank-cards/services/bank-card.service'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { InlineKeyboard } from 'grammy'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; @OnTelegramCallback( Callback.BOT_SUBSCRIPTION.MENU, Callback.BOT_SUBSCRIPTION.ENTER_DISCOUNT, Callback.BOT_SUBSCRIPTION.SKIP_DISCOUNT, { pattern: Callback.BOT_SUBSCRIPTION.SELECT_PLAN, matchType: 'startsWith' }, ) @Injectable() export class BotSubscriptionHandler implements TelegramHandler { private readonly logger = new Logger(BotSubscriptionHandler.name); constructor( private readonly botPlanService: BotPlanService, private readonly paymentRequestService: PaymentRequestService, private readonly bankCardService: BankCardService, private readonly sessionService: TelegramSessionService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.currentOwner) return; try { if (data.startsWith(Callback.BOT_SUBSCRIPTION.SELECT_PLAN)) return this.promptDiscount(ctx, data); if (data === Callback.BOT_SUBSCRIPTION.MENU) return this.showPlans(ctx); if (data === Callback.BOT_SUBSCRIPTION.ENTER_DISCOUNT) return this.enterDiscount(ctx); if (data === Callback.BOT_SUBSCRIPTION.SKIP_DISCOUNT) return this.skipDiscount(ctx); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showPlans(ctx: TelegramContext): Promise { const plans = await this.botPlanService.findActive(); const mapped = plans.map((p) => ({ id: p.id, title: p.title, price: Number(p.price) })); if (!mapped.length) { await ctx.editMessageText('❌ فعلاً پلنی برای اشتراک ربات تعریف نشده است.'); return; } await ctx.editMessageText('💳 پلن اشتراک ربات را انتخاب کنید:', { reply_markup: BotSubscriptionKeyboard.planList(mapped), }); } // بعد private async promptDiscount(ctx: TelegramContext, data: string): Promise { const botPlanId = data.split(':')[2]; const plan = await this.botPlanService.findById(botPlanId); if (!plan) { await ctx.editMessageText(TelegramMessages.PLAN_NOT_FOUND); return; } await this.sessionService.update(ctx.currentUser!.id, { step: 'IDLE', data: { ...ctx.session?.data, pendingPurchase: { kind: 'BOT_PLAN', botPlanId } }, }); await ctx.editMessageText(TelegramMessages.ASK_DISCOUNT_CODE, { reply_markup: new InlineKeyboard() .text('🎟 دارم', Callback.BOT_SUBSCRIPTION.ENTER_DISCOUNT) .text('رد شدن، بدون کد', Callback.BOT_SUBSCRIPTION.SKIP_DISCOUNT) .row() .text('🔙 بازگشت', Callback.BOT_SUBSCRIPTION.MENU), }); } private async enterDiscount(ctx: TelegramContext): Promise { await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_REDEEM_DISCOUNT_CODE', data: ctx.session!.data, }); await ctx.editMessageText(TelegramMessages.ENTER_DISCOUNT_CODE); } private async skipDiscount(ctx: TelegramContext): Promise { const pending = ctx.session?.data.pendingPurchase; if (!pending?.botPlanId) { await ctx.editMessageText(TelegramMessages.PLAN_FLOW_EXPIRED); return; } await this.startBotPayment(ctx, pending.botPlanId); } // 🆕 public تا از WaitingRedeemDiscountCodeHandler هم صدا زده بشه async startBotPayment( ctx: TelegramContext, planId: string, override?: { discountCodeId?: string; finalAmount?: number }, ): Promise { const plan = await this.botPlanService.findById(planId); if (!plan) { await ctx.reply(TelegramMessages.PLAN_NOT_FOUND); return; } const adminCard = await this.bankCardService.findAnyAdminWithDefaultCard(); if (!adminCard) { // 🔧 اصل ۷ سند: پیام واضح + مسیر خروج، نه Dead-end await ctx.reply( '❌ سازنده ربات هنوز کارت بانکی ثبت نکرده است. لطفاً بعداً تلاش کنید.', { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK) }, ); return; } const amount = override?.finalAmount ?? Number(plan.price); const paymentRequest = await this.paymentRequestService.createForBotPlan({ payerId: ctx.currentUser!.id, botPlanId: planId, amount, receiverAdminUserId: adminCard.adminUserId!, bankCardId: adminCard.id, discountCodeId: override?.discountCodeId, }); await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_PAYMENT_RECEIPT', data: { pendingPaymentRequestId: paymentRequest.id }, }); await ctx.reply( [ `💳 مبلغ قابل پرداخت: ${amount.toLocaleString()} تومان`, override?.discountCodeId ? '🎟 کد تخفیفت اعمال شد، دمت گرم!' : '', '', `به نام: ${adminCard.holderName}`, 'برای کپی شماره کارت، روی دکمه‌ی زیر بزن 👇', '', `🔑 کد پیگیریت: ${paymentRequest.paymentCode}`, 'اگه امکانش هست، این کد رو در توضیحات انتقال بنویس تا سریع‌تر پیدات کنم.', '', '📸 پرداخت که انجام شد، عکس فیشش رو همین‌جا برام بفرست تا برای تایید بفرستمش.', ].filter(Boolean).join('\n'), { reply_markup: new InlineKeyboard() .copyText(`💳 ${adminCard.cardNumber}`, adminCard.cardNumber) .row(), }, ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\broadcast.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { ChannelService } from 'src/modules/channels/services/channel.service'; import { BroadcastService } from 'src/modules/broadcast/services/broadcast.service'; import { BroadcastKeyboard } from '../../keyboards/broadcast.keyboard'; import { OwnerKeyboard } from '../../keyboards/owner.keyboard'; @OnTelegramCallback( Callback.BROADCAST.MENU, { pattern: Callback.BROADCAST.SELECT_TARGET, matchType: 'startsWith' }, Callback.BROADCAST.CONFIRM, Callback.BROADCAST.CANCEL, ) @Injectable() export class BroadcastHandler implements TelegramHandler { private readonly logger = new Logger(BroadcastHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly channelService: ChannelService, private readonly broadcastService: BroadcastService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.currentOwner) return; try { if (data.startsWith(Callback.BROADCAST.SELECT_TARGET)) return this.selectTarget(ctx, data); switch (data) { case Callback.BROADCAST.MENU: return this.showTargets(ctx); case Callback.BROADCAST.CONFIRM: return this.finalize(ctx); case Callback.BROADCAST.CANCEL: await this.sessionService.resetStep(ctx.currentUser.id); await ctx.editMessageText('پنل مدیریت', { reply_markup: OwnerKeyboard.menu() }); return; } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showTargets(ctx: TelegramContext): Promise { const channels = await this.channelService.getOwnerChannels(ctx.currentOwner!.id); await ctx.editMessageText(TelegramMessages.SELECT_BROADCAST_TARGET, { reply_markup: BroadcastKeyboard.selectTarget(channels), }); } private async selectTarget(ctx: TelegramContext, data: string): Promise { const target = data.split(':')[2]; // 'all' یا channelId // 🔒 اگه channelId خاص انتخاب شد، مالکیتش رو چک کن if (target !== 'all') { const channel = await this.channelService.findById(target); if (!channel || channel.ownerId !== ctx.currentOwner!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } } await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_BROADCAST_CONTENT', data: { broadcastChannelId: target === 'all' ? undefined : target }, }); await ctx.editMessageText(TelegramMessages.ENTER_BROADCAST_CONTENT); } // بعد private async finalize(ctx: TelegramContext): Promise { const { broadcastPendingContent, broadcastChannelId } = ctx.session!.data as any; if (!broadcastPendingContent) { await ctx.editMessageText(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser!.id); return; } await this.broadcastService.create({ ownerId: ctx.currentOwner!.id, content: broadcastPendingContent, channelId: broadcastChannelId, }); await this.sessionService.resetStep(ctx.currentUser!.id); await ctx.editMessageText(TelegramMessages.BROADCAST_QUEUED); await ctx.reply('پنل مدیریت', { reply_markup: OwnerKeyboard.menu() }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\channel-menu.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { ChannelService } from 'src/modules/channels/services/channel.service'; import { ChannelKeyboard } from '../../keyboards/channel.keyboard'; import { InlineKeyboard } from 'grammy'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; import { TelegramService } from '../../services/telegram.service'; import { BotSubscriptionKeyboard } from '../../keyboards/bot-subscription.keyboard'; import { BotSubscriptionService } from 'src/modules/bot-subscriptions/services/bot-subscription.service'; import { BankCardService } from 'src/modules/bank-cards/services/bank-card.service'; import { PlanService } from 'src/modules/plans/services/plan.service'; @OnTelegramCallback( { pattern: Callback.CHANNEL.MENU, matchType: 'startsWith' }, { pattern: Callback.CHANNEL.DETAIL, matchType: 'startsWith' }, Callback.CHANNEL.ADD, Callback.CHANNEL.LIST, Callback.CHANNEL.REFRESH, { pattern: Callback.CHANNEL.GET_LINK, matchType: 'startsWith' }, { pattern: Callback.CHANNEL.DELETE_CONFIRM, matchType: 'startsWith' }, { pattern: Callback.CHANNEL.DELETE, matchType: 'startsWith' }, ) @Injectable() export class ChannelMenuHandler implements TelegramHandler { private readonly logger = new Logger(ChannelMenuHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly telegramService: TelegramService, private readonly channelService: ChannelService, private readonly botSubscriptionService: BotSubscriptionService, private readonly planService: PlanService, private readonly bankCardService: BankCardService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.currentOwner) return; try { if (data.startsWith(Callback.CHANNEL.DETAIL)) return this.showDetail(ctx, data); if (data.startsWith(Callback.CHANNEL.GET_LINK)) return this.getLink(ctx, data); if (data.startsWith(Callback.CHANNEL.DELETE_CONFIRM)) return this.deleteChannel(ctx, data); if (data.startsWith(Callback.CHANNEL.DELETE)) return this.askDelete(ctx, data); if (data.startsWith(Callback.CHANNEL.MENU)) { const page = Number(data.split(':')[2]) || 0; return this.showList(ctx, page); } if (data === Callback.CHANNEL.ADD) { const hasActiveBotSub = await this.botSubscriptionService.isActive(ctx.currentOwner.id); if (!hasActiveBotSub) { await ctx.editMessageText( '⛔ قبل از ثبت کانال جدید، باید اول اشتراک ربات رو بخری یا تمدید کنی.', { reply_markup: BotSubscriptionKeyboard.renewButton() }, ); return; } const guideMessageId = ctx.callbackQuery?.message?.message_id; await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_CHANNEL_ADMIN_CONFIRM', data: { channelGuideMessageId: guideMessageId }, }); await ctx.editMessageText(TelegramMessages.ENTER_CHANNEL_ID, { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, `${Callback.CHANNEL.MENU}:0`), }); return; } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showList(ctx: TelegramContext, page: number, asNewMessage = false): Promise { const pageSize = 10; const [channels, total] = await Promise.all([ this.channelService.getOwnerChannelsPaginated(ctx.currentOwner!.id, page, pageSize), this.channelService.countByOwner(ctx.currentOwner!.id), ]); const text = channels.length ? '📺 کانال‌های شما:' : 'هنوز کانالی ثبت نکرده‌اید.'; const hasNextPage = (page + 1) * pageSize < total; const keyboard = ChannelKeyboard.list(channels, page, hasNextPage); if (asNewMessage) { await ctx.reply(text, { reply_markup: keyboard }); } else { await ctx.editMessageText(text, { reply_markup: keyboard }); } } private async getLink(ctx: TelegramContext, data: string): Promise { const channelId = data.split(':')[2]; const channel = await this.channelService.findById(channelId); if (!channel || channel.ownerId !== ctx.currentOwner!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } const plans = await this.planService.getChannelPlansForPurchase(channelId); if (!plans.length) { await ctx.answerCallbackQuery({ text: '❌ هنوز پلنی برای این کانال نساختی.', show_alert: true, }); return; } const card = await this.bankCardService.getDefaultForOwner(ctx.currentOwner!.id); if (!card) { await ctx.answerCallbackQuery({ text: '❌ اول باید یه کارت بانکی ثبت کنی.', show_alert: true, }); return; } const botUsername = ctx.me.username; const link = `https://t.me/${botUsername}?start=sub_${channelId}`; await ctx.answerCallbackQuery(); await ctx.reply(`🔗 لینک خرید اشتراک این کانال:\n\`${link}\``, { parse_mode: 'MarkdownV2' }); await this.showDetail(ctx, `${Callback.CHANNEL.DETAIL}:${channelId}`); } private async showDetail(ctx: TelegramContext, data: string): Promise { const channelId = data.split(':')[2]; const channel = await this.channelService.findById(channelId); if (!channel || channel.ownerId !== ctx.currentOwner!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await ctx.editMessageText(`📺 ${channel.title}`, { reply_markup: ChannelKeyboard.detail(channelId) }); } private async askDelete(ctx: TelegramContext, data: string): Promise { const channelId = data.split(':')[2]; const channel = await this.channelService.findById(channelId); if (!channel || channel.ownerId !== ctx.currentOwner!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await ctx.editMessageText(TelegramMessages.CHANNEL_DELETE_CONFIRM(channel.title), { reply_markup: ChannelKeyboard.deleteConfirm(channelId), }); } private async deleteChannel(ctx: TelegramContext, data: string): Promise { const channelId = data.split(':')[2]; const channel = await this.channelService.findById(channelId); if (!channel || channel.ownerId !== ctx.currentOwner!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await this.channelService.deactivate(channelId); await ctx.answerCallbackQuery({ text: TelegramMessages.CHANNEL_DELETED }); await this.showList(ctx, 0); } private async recheckChannelSettings(ctx: TelegramContext): Promise { const pending = ctx.session?.data; if (!pending?.channelId || !pending?.title) { await ctx.answerCallbackQuery({ text: TelegramMessages.PLAN_FLOW_EXPIRED, show_alert: true }); return; } const chatInfo: any = await this.telegramService.getBot().api.getChat(pending.channelId); const isPrivate = !chatInfo.username; // const hasJoinApproval = Boolean(chatInfo.join_by_request); // if (!isPrivate || !hasJoinApproval) { if (!isPrivate) { await ctx.answerCallbackQuery({ text: '❌ هنوز تنظیمات درست نشده. لطفاً موارد خواسته‌شده را انجام دهید.', show_alert: true, }); return; } await this.channelService.createChannel({ telegramChannelId: pending.channelId, title: pending.title, username: chatInfo.username ?? undefined, ownerId: ctx.currentOwner!.id, }); const guideMessageId = pending.channelGuideMessageId; if (guideMessageId) { await this.telegramService.getBot().api.deleteMessage(ctx.chat!.id, guideMessageId).catch(() => undefined); } await this.sessionService.resetStep(ctx.currentUser!.id); await ctx.editMessageText('✅ کانال با موفقیت ثبت شد.'); await ctx.reply(TelegramMessages.CHANNEL_REGISTERED_NEXT_STEPS); await ctx.reply('📺 کانال‌ها', { reply_markup: ChannelKeyboard.backToList() }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\discount-menu.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { PlanService } from 'src/modules/plans/services/plan.service'; import { DiscountService } from 'src/modules/discounts/services/discount.service'; import { DiscountKeyboard } from '../../keyboards/discount.keyboard'; import { InlineKeyboard } from 'grammy'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; @OnTelegramCallback( { pattern: Callback.DISCOUNT.MENU, matchType: 'startsWith' }, Callback.DISCOUNT.CREATE, { pattern: Callback.DISCOUNT.SELECT_PLAN, matchType: 'startsWith' }, { pattern: Callback.DISCOUNT.SELECT_TYPE, matchType: 'startsWith' }, Callback.DISCOUNT.SKIP_MAX_USAGE, Callback.DISCOUNT.SKIP_EXPIRY, Callback.DISCOUNT.CONFIRM_CREATE, Callback.DISCOUNT.CANCEL_CREATE, Callback.DISCOUNT.LIST, { pattern: Callback.DISCOUNT.DELETE, matchType: 'startsWith' }, ) @Injectable() export class DiscountMenuHandler implements TelegramHandler { private readonly logger = new Logger(DiscountMenuHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly planService: PlanService, private readonly discountService: DiscountService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.currentOwner) return; try { if (data.startsWith(Callback.DISCOUNT.SELECT_PLAN)) return this.selectPlan(ctx, data); if (data.startsWith(Callback.DISCOUNT.SELECT_TYPE)) return this.selectType(ctx, data); if (data.startsWith(Callback.DISCOUNT.DELETE)) return this.deleteCode(ctx, data); if (data.startsWith(Callback.DISCOUNT.MENU)) { const page = Number(data.split(':')[2]) || 0; return this.showList(ctx, page); } switch (data) { case Callback.DISCOUNT.CREATE: return this.startCreate(ctx); case Callback.DISCOUNT.SKIP_MAX_USAGE: return this.skipMaxUsage(ctx); case Callback.DISCOUNT.SKIP_EXPIRY: return this.skipExpiry(ctx); case Callback.DISCOUNT.CONFIRM_CREATE: return this.finalizeCreate(ctx); case Callback.DISCOUNT.CANCEL_CREATE: await this.sessionService.resetStep(ctx.currentUser.id); return this.showList(ctx, 0); } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showList(ctx: TelegramContext, page: number, asNewMessage = false): Promise { const pageSize = 10; const [codes, total] = await Promise.all([ this.discountService.listByOwnerPaginated(ctx.currentOwner!.id, page, pageSize), this.discountService.countByOwner(ctx.currentOwner!.id), ]); const text = codes.length ? '🎟 کدهای تخفیف شما:' : TelegramMessages.NO_DISCOUNT_CODES; const hasNextPage = (page + 1) * pageSize < total; const keyboard = DiscountKeyboard.list(codes, page, hasNextPage); if (asNewMessage) { await ctx.reply(text, { reply_markup: keyboard }); } else { await ctx.editMessageText(text, { reply_markup: keyboard }); } } private async startCreate(ctx: TelegramContext): Promise { const plans = await this.planService.getOwnerPlans(ctx.currentOwner!.id); if (!plans.length) { // 🔧 اصل ۷ سند: پیام + بازگشت، نه Dead-end await ctx.editMessageText(TelegramMessages.NO_PLANS_FOR_DISCOUNT, { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, `${Callback.DISCOUNT.MENU}:0`), }); return; } await ctx.editMessageText(TelegramMessages.SELECT_PLAN_FOR_DISCOUNT, { reply_markup: DiscountKeyboard.selectPlan(plans), }); } private async selectPlan(ctx: TelegramContext, data: string): Promise { const planId = data.split(':')[2]; await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_NEW_DISCOUNT_CODE', data: { newDiscountPlanId: planId }, }); await ctx.editMessageText(TelegramMessages.ENTER_NEW_DISCOUNT_CODE); } private async selectType(ctx: TelegramContext, data: string): Promise { const type = data.split(':')[2] as 'PERCENTAGE' | 'FIXED'; await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_NEW_DISCOUNT_VALUE', data: { ...ctx.session!.data, newDiscountType: type }, }); await ctx.editMessageText(TelegramMessages.ENTER_DISCOUNT_VALUE(type)); } private async skipMaxUsage(ctx: TelegramContext): Promise { await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_NEW_DISCOUNT_EXPIRY_DAYS', data: ctx.session!.data, }); await ctx.editMessageText(TelegramMessages.ASK_DISCOUNT_EXPIRY, { reply_markup: DiscountKeyboard.skipExpiry(), }); } private async skipExpiry(ctx: TelegramContext): Promise { await this.showSummary(ctx); } private async showSummary(ctx: TelegramContext): Promise { const { newDiscountCode, newDiscountType, newDiscountValue, newDiscountMaxUsage, newDiscountExpiresInDays } = ctx.session!.data; const typeFa = newDiscountType === 'PERCENTAGE' ? 'درصدی' : 'مبلغ ثابت'; const valueFa = newDiscountType === 'PERCENTAGE' ? `${newDiscountValue}٪` : `${newDiscountValue?.toLocaleString()} تومان`; await ctx.editMessageText( [ '📋 خلاصه کد تخفیف:', `کد: ${newDiscountCode}`, `نوع: ${typeFa}`, `مقدار: ${valueFa}`, `سقف استفاده: ${newDiscountMaxUsage ? newDiscountMaxUsage : 'نامحدود'}`, `انقضا: ${newDiscountExpiresInDays ? `${newDiscountExpiresInDays} روز دیگر` : 'ندارد'}`, '', 'آیا تایید می‌کنید؟', ].join('\n'), { reply_markup: DiscountKeyboard.confirmCreate() }, ); } private async finalizeCreate(ctx: TelegramContext): Promise { const { newDiscountPlanId, newDiscountCode, newDiscountType, newDiscountValue, newDiscountMaxUsage, newDiscountExpiresInDays, } = ctx.session!.data; if (!newDiscountPlanId || !newDiscountCode || !newDiscountType || !newDiscountValue) { await ctx.editMessageText(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser!.id); await this.showList(ctx, 0, true); return; } const expiresAt = newDiscountExpiresInDays ? new Date(Date.now() + newDiscountExpiresInDays * 24 * 60 * 60 * 1000) : undefined; await this.discountService.createForOwner({ code: newDiscountCode, type: newDiscountType, value: newDiscountValue, ownerId: ctx.currentOwner!.id, planId: newDiscountPlanId, maxUsage: newDiscountMaxUsage, expiresAt, }); await this.sessionService.resetStep(ctx.currentUser!.id); await ctx.editMessageText(TelegramMessages.DISCOUNT_CODE_CREATED); await this.showList(ctx, 0, true); } private async deleteCode(ctx: TelegramContext, data: string): Promise { const id = data.split(':')[2]; const ok = await this.discountService.deactivateForOwner(id, ctx.currentOwner!.id); if (!ok) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await ctx.answerCallbackQuery({ text: TelegramMessages.DISCOUNT_DEACTIVATED }); await this.showList(ctx, 0); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\help.handler.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { InlineKeyboard } from 'grammy'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; @OnTelegramCallback(Callback.HELP.MENU) @Injectable() export class HelpHandler implements TelegramHandler { async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser) return; await ctx.editMessageText(TelegramMessages.HELP_GUIDE_TEXT, { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\join-check.handler.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; @OnTelegramCallback(Callback.JOIN.CHECK) @Injectable() export class JoinCheckHandler implements TelegramHandler { async execute(ctx: TelegramContext): Promise { // await ctx.answerCallbackQuery({ text: '✅ عضویت تایید شد' }); await ctx.reply('✅ عضویت شما تایید شد. دستور /start را بزنید.'); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\member-menu.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { ChannelService } from 'src/modules/channels/services/channel.service'; import { SubscriptionService } from 'src/modules/subscriptions/services/subscription.service'; import { MemberKeyboard } from '../../keyboards/member.keyboard'; import { formatPersianDate } from 'src/common/utils/date.util'; @OnTelegramCallback( Callback.MEMBER.MENU, { pattern: Callback.MEMBER.CHANNEL, matchType: 'startsWith' }, { pattern: Callback.MEMBER.ACTIVE, matchType: 'startsWith' }, { pattern: Callback.MEMBER.EXPIRED, matchType: 'startsWith' }, { pattern: Callback.MEMBER.SEARCH, matchType: 'startsWith' }, { pattern: Callback.MEMBER.REMOVE, matchType: 'startsWith' }, ) @Injectable() export class MemberMenuHandler implements TelegramHandler { private readonly logger = new Logger(MemberMenuHandler.name); constructor( private readonly channelService: ChannelService, private readonly subscriptionService: SubscriptionService, private readonly sessionService: TelegramSessionService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.currentOwner) return; try { if (data.startsWith(Callback.MEMBER.CHANNEL)) return this.showSubmenu(ctx, data); if (data.startsWith(Callback.MEMBER.ACTIVE)) return this.showActive(ctx, data); if (data.startsWith(Callback.MEMBER.EXPIRED)) return this.showExpired(ctx, data); if (data.startsWith(Callback.MEMBER.SEARCH)) return this.askSearch(ctx, data); if (data.startsWith(Callback.MEMBER.REMOVE)) return this.removeMember(ctx, data); if (data === Callback.MEMBER.MENU) return this.showChannelList(ctx); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } // 🔒 چک مالکیت مشترک برای همه‌ی متدهایی که channelId میگیرن private async assertOwnership(ctx: TelegramContext, channelId: string): Promise { const channel = await this.channelService.findById(channelId); if (!channel || channel.ownerId !== ctx.currentOwner!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return false; } return true; } private async showChannelList(ctx: TelegramContext): Promise { const channels = await this.channelService.getOwnerChannels(ctx.currentOwner!.id); if (!channels.length) { await ctx.editMessageText(TelegramMessages.NO_CHANNELS_FOR_MEMBERS, { reply_markup: MemberKeyboard.channelList([]), }); return; } await ctx.editMessageText('👥 برای کدام کانال می‌خواهید اعضا را مدیریت کنید؟', { reply_markup: MemberKeyboard.channelList(channels), }); } private async showSubmenu(ctx: TelegramContext, data: string): Promise { const channelId = data.split(':')[2]; if (!(await this.assertOwnership(ctx, channelId))) return; await ctx.editMessageText('👥 مدیریت اعضا', { reply_markup: MemberKeyboard.submenu(channelId) }); } private async showActive(ctx: TelegramContext, data: string): Promise { const [, , channelId, pageStr] = data.split(':'); const page = Number(pageStr) || 0; if (!(await this.assertOwnership(ctx, channelId))) return; const pageSize = 20; const [members, total] = await Promise.all([ this.subscriptionService.findActiveByChannel(channelId, page, pageSize), this.subscriptionService.countActiveByChannel(channelId), ]); if (!members.length) { await ctx.editMessageText(TelegramMessages.NO_ACTIVE_MEMBERS, { reply_markup: MemberKeyboard.submenu(channelId), }); return; } const mapped = members.map((m) => ({ subscriptionId: m.id, label: `🟢 ${m.user.firstName ?? m.user.username ?? m.user.telegramId} — تا ${formatPersianDate(m.expiresAt)}`, })); const hasNextPage = (page + 1) * pageSize < total; await ctx.editMessageText(`🟢 اعضای فعال (صفحه ${page + 1}):`, { reply_markup: MemberKeyboard.memberListWithRemove(mapped, channelId, page, hasNextPage), }); } // private async showActive(ctx: TelegramContext, data: string): Promise { // const channelId = data.split(':')[2]; // if (!(await this.assertOwnership(ctx, channelId))) return; // const members = await this.subscriptionService.findActiveByChannel(channelId); // if (!members.length) { // await ctx.editMessageText(TelegramMessages.NO_ACTIVE_MEMBERS, { // reply_markup: MemberKeyboard.submenu(channelId), // }); // return; // } // const mapped = members.map((m) => ({ // subscriptionId: m.id, // label: `🟢 ${m.user.firstName ?? m.user.username ?? m.user.telegramId} — تا ${formatPersianDate(m.expiresAt)}`, // })); // await ctx.editMessageText('🟢 اعضای فعال:', { // reply_markup: MemberKeyboard.memberListWithRemove(mapped, channelId), // }); // } private async showExpired(ctx: TelegramContext, data: string): Promise { const channelId = data.split(':')[2]; if (!(await this.assertOwnership(ctx, channelId))) return; const members = await this.subscriptionService.findExpiredByChannel(channelId); if (!members.length) { await ctx.editMessageText(TelegramMessages.NO_EXPIRED_MEMBERS, { reply_markup: MemberKeyboard.submenu(channelId), }); return; } const text = members .map((m) => `🔴 ${m.user.firstName ?? m.user.username ?? m.user.telegramId}`) .join('\n'); await ctx.editMessageText(text, { reply_markup: MemberKeyboard.submenu(channelId) }); } private async askSearch(ctx: TelegramContext, data: string): Promise { const channelId = data.split(':')[2]; if (!(await this.assertOwnership(ctx, channelId))) return; await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_MEMBER_SEARCH_QUERY', data: { ...ctx.session?.data, memberSearchChannelId: channelId }, }); await ctx.editMessageText(TelegramMessages.ENTER_MEMBER_SEARCH_QUERY, { reply_markup: MemberKeyboard.submenu(channelId), }); } private async removeMember(ctx: TelegramContext, data: string): Promise { const subscriptionId = data.split(':')[2]; const subscription = await this.subscriptionService.findById(subscriptionId); if (!subscription || subscription.channel.ownerId !== ctx.currentOwner!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await this.subscriptionService.manualRemove(subscriptionId); await ctx.answerCallbackQuery({ text: TelegramMessages.MEMBER_REMOVED }); await this.showActive(ctx, `${Callback.MEMBER.ACTIVE}::${subscription.channelId}`); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\my-payment-requests.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { InlineKeyboard } from 'grammy'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { PaymentRequestService } from 'src/modules/payment-requests/services/payment-request.service'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; @OnTelegramCallback( Callback.PAYMENT_REQUEST.MY_LIST, { pattern: Callback.PAYMENT_REQUEST.RESUME, matchType: 'startsWith' }, ) @Injectable() export class MyPaymentRequestsHandler implements TelegramHandler { private readonly logger = new Logger(MyPaymentRequestsHandler.name); constructor( private readonly paymentRequestService: PaymentRequestService, private readonly sessionService: TelegramSessionService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser) return; try { if (data.startsWith(Callback.PAYMENT_REQUEST.RESUME)) return this.resume(ctx, data); if (data === Callback.PAYMENT_REQUEST.MY_LIST) return this.showList(ctx); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showList(ctx: TelegramContext): Promise { const requests = await this.paymentRequestService.findPendingByPayer(ctx.currentUser!.id); if (!requests.length) { await ctx.editMessageText( '📸 شما درخواست پرداخت در انتظار ارسال فیش ندارید.', { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK) }, ); return; } const keyboard = new InlineKeyboard(); for (const r of requests) { const statusFa = r.status === 'Rejected' ? '❌ ردشده' : '⏳ منتظر فیش'; keyboard .text(`${r.paymentCode} — ${r.amount.toLocaleString()} تومان (${statusFa})`, `${Callback.PAYMENT_REQUEST.RESUME}:${r.id}`) .row(); } keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); await ctx.editMessageText('📸 درخواست‌های در انتظار ارسال فیش:', { reply_markup: keyboard }); } private async resume(ctx: TelegramContext, data: string): Promise { const id = data.split(':')[2]; const request = await this.paymentRequestService.findById(id); if (!request || request.payerId !== ctx.currentUser!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } if (request.status !== 'WaitingReceipt' && request.status !== 'Rejected') { await ctx.answerCallbackQuery({ text: '❌ این درخواست دیگر قابل ارسال فیش نیست.', show_alert: true }); return; } await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_PAYMENT_RECEIPT', data: { pendingPaymentRequestId: id }, }); await ctx.editMessageText( [ `💳 مبلغ قابل پرداخت: ${request.amount.toLocaleString()} تومان`, `🔑 کد پیگیری: ${request.paymentCode}`, '', '📸 تصویر فیش را همین‌جا ارسال کنید.', ].join('\n'), ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\navigation.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { UserKeyboard } from '../../keyboards/user.keyboard'; import { OwnerKeyboard } from '../../keyboards/owner.keyboard'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; @OnTelegramCallback(Callback.NAVIGATION.BACK) @Injectable() export class NavigationHandler implements TelegramHandler { private readonly logger = new Logger(NavigationHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser) return; try { await this.sessionService.resetStep(ctx.currentUser.id); // 🔑 هر فلوی فعالی لغو میشه switch (ctx.role) { // 🔑 نقش واقعی، نه فرض ثابت case 'ADMIN': await ctx.editMessageText('👑 پنل سازنده ربات', { reply_markup: AdminKeyboard.menu() }); return; case 'OWNER': await ctx.editMessageText('پنل مدیریت', { reply_markup: OwnerKeyboard.menu() }); return; default: await ctx.editMessageText('👤 پنل شما', { reply_markup: UserKeyboard.menu() }); return; } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\noop.handler.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; // دکمه‌های صرفاً نمایشی (مثل عنوان یک آیتم در لیست) به این کال‌بک وصل می‌شن. // خودِ Registry بعد از اجرا اگه answerCallbackQuery صدا زده نشده باشه، // خودکار جواب خالی می‌ده؛ پس این Handler فقط باید رجیستر بشه، کاری لازم نیست بکنه. @OnTelegramCallback('noop') @Injectable() export class NoopHandler implements TelegramHandler { async execute(_ctx: TelegramContext): Promise { return; } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\outage-confirmation.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { ServiceOutageService } from 'src/modules/service-outage/services/service-outage.service'; @OnTelegramCallback( { pattern: Callback.OUTAGE.CONFIRM, matchType: 'startsWith' }, { pattern: Callback.OUTAGE.REJECT, matchType: 'startsWith' }, ) @Injectable() export class OutageConfirmationHandler implements TelegramHandler { private readonly logger = new Logger(OutageConfirmationHandler.name); constructor(private readonly outageService: ServiceOutageService) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.isAdmin) return; // 🔒 گارد مستقل try { const outageId = data.split(':')[2]; if (data.startsWith(Callback.OUTAGE.CONFIRM)) { await this.outageService.confirm(outageId, 'ADMIN'); await ctx.editMessageText('✅ قطعی تایید شد. اعتبار اشتراک‌های متاثر اصلاح خواهد شد.'); return; } if (data.startsWith(Callback.OUTAGE.REJECT)) { await this.outageService.reject(outageId); await ctx.editMessageText('❌ به‌عنوان خطای موقت ثبت شد. هیچ اصلاحی اعمال نمی‌شود.'); return; } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: '❌ خطایی رخ داد', show_alert: true }); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\owner-member-ban.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { SubscriptionService } from 'src/modules/subscriptions/services/subscription.service'; import { ChannelBanService } from 'src/modules/channel-bans/services/channel-ban.service'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { InlineKeyboard } from 'grammy'; @OnTelegramCallback( { pattern: Callback.BAN.MEMBER_MENU, matchType: 'startsWith' }, { pattern: Callback.BAN.MEMBER_TEMP, matchType: 'startsWith' }, { pattern: Callback.BAN.MEMBER_PERMANENT, matchType: 'startsWith' }, { pattern: Callback.BAN.MEMBER_UNBAN, matchType: 'startsWith' }, ) @Injectable() export class OwnerMemberBanHandler implements TelegramHandler { private readonly logger = new Logger(OwnerMemberBanHandler.name); constructor( private readonly subscriptionService: SubscriptionService, private readonly channelBanService: ChannelBanService, private readonly sessionService: TelegramSessionService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.currentOwner) return; try { if (data.startsWith(Callback.BAN.MEMBER_MENU)) return this.showActions(ctx, data); if (data.startsWith(Callback.BAN.MEMBER_TEMP)) return this.askDuration(ctx, data); if (data.startsWith(Callback.BAN.MEMBER_PERMANENT)) return this.askReason(ctx, data); if (data.startsWith(Callback.BAN.MEMBER_UNBAN)) return this.unban(ctx, data); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } // 🔒 چک مالکیت مشترک: subscription باید متعلق به کانال همین Owner باشه private async loadAndVerify(ctx: TelegramContext, subscriptionId: string) { const subscription = await this.subscriptionService.findById(subscriptionId); if (!subscription || subscription.channel.ownerId !== ctx.currentOwner!.id) return null; return subscription; } // بعد private async showActions(ctx: TelegramContext, data: string): Promise { const subscriptionId = data.split(':')[2]; const subscription = await this.loadAndVerify(ctx, subscriptionId); if (!subscription) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } const isBanned = await this.channelBanService.isBanned(subscription.channelId, subscription.userId); const keyboard = new InlineKeyboard(); if (isBanned) { keyboard.text('🔓 رفع بن', `${Callback.BAN.MEMBER_UNBAN}:${subscriptionId}`).row(); } else { keyboard .text('⏳ بن موقت', `${Callback.BAN.MEMBER_TEMP}:${subscriptionId}`) .text('⛔ بن دائم', `${Callback.BAN.MEMBER_PERMANENT}:${subscriptionId}`) .row(); } // 🆕 بازگشت به لیست اعضای همون کانال keyboard.text('⬅️ بازگشت', `${Callback.MEMBER.ACTIVE}:${subscription.channelId}`); await ctx.editMessageText( `🚫 مدیریت بن برای ${subscription.user.firstName ?? subscription.user.username ?? subscription.user.telegramId}`, { reply_markup: keyboard }, ); } private async askDuration(ctx: TelegramContext, data: string): Promise { const subscriptionId = data.split(':')[2]; const subscription = await this.loadAndVerify(ctx, subscriptionId); if (!subscription) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_BAN_DURATION_DAYS', data: { banTargetType: 'USER', banTargetId: subscription.userId, banChannelId: subscription.channelId }, }); await ctx.editMessageText('⏳ چند روز این عضو رو بن کنیم؟ فقط عدد بنویس (مثلاً برای یک هفته بنویس: 7)',); } private async askReason(ctx: TelegramContext, data: string): Promise { const subscriptionId = data.split(':')[2]; const subscription = await this.loadAndVerify(ctx, subscriptionId); if (!subscription) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_BAN_REASON', data: { banTargetType: 'USER', banTargetId: subscription.userId, banChannelId: subscription.channelId }, }); await ctx.editMessageText('📝 دلیل بن دائمش چیه؟ (این دلیل فقط برای خودت ذخیره می‌شه)',); } private async unban(ctx: TelegramContext, data: string): Promise { const subscriptionId = data.split(':')[2]; const subscription = await this.loadAndVerify(ctx, subscriptionId); if (!subscription) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await this.channelBanService.unban(subscription.channelId, subscription.userId); await ctx.answerCallbackQuery({ text: '✅ بن این عضو برداشته شد، دوباره می‌تونه از کانال استفاده کنه.', }); await this.showActions(ctx, `${Callback.BAN.MEMBER_MENU}:${subscriptionId}`); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\payment-approval.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { PaymentRequestService } from 'src/modules/payment-requests/services/payment-request.service'; import { TelegramApiService } from '../../services/telegram-api.service'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { formatPersianDate } from 'src/common/utils/date.util'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; import { OwnerKeyboard } from '../../keyboards/owner.keyboard'; import { UserKeyboard } from '../../keyboards/user.keyboard'; @OnTelegramCallback( { pattern: Callback.PAYMENT_REQUEST.APPROVE, matchType: 'startsWith' }, { pattern: Callback.PAYMENT_REQUEST.REJECT, matchType: 'startsWith' }, ) @Injectable() export class PaymentApprovalHandler implements TelegramHandler { private readonly logger = new Logger(PaymentApprovalHandler.name); constructor( private readonly paymentRequestService: PaymentRequestService, private readonly telegramApiService: TelegramApiService, private readonly sessionService: TelegramSessionService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.currentOwner) return; try { if (data.startsWith(Callback.PAYMENT_REQUEST.APPROVE)) return this.approve(ctx, data); if (data.startsWith(Callback.PAYMENT_REQUEST.REJECT)) return this.askRejectReason(ctx, data); } catch (error: any) { this.logger.error(error); await ctx.answerCallbackQuery({ text: error.message ?? TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async approve(ctx: TelegramContext, data: string): Promise { const paymentRequestId = data.split(':')[2]; const request = await this.paymentRequestService.findById(paymentRequestId); if (!request) { await ctx.answerCallbackQuery({ text: 'درخواست پیدا نشد', show_alert: true }); return; } if (request.type === 'BOT_SUBSCRIPTION') { if (!ctx.isAdmin) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } const result = await this.paymentRequestService.approveBotSubscription(paymentRequestId, ctx.currentUser!.id); await ctx.answerCallbackQuery({ text: '✅ اشتراک ربات فعال شد' }); await ctx.editMessageCaption({ caption: '✅ این پرداخت تایید شد.' }); await this.telegramApiService.sendMessageWithKeyboard( result.request.payer.telegramId, `✅ ایول، پرداختت تایید شد و اشتراک ربات فعال/تمدید شد.\nاعتبارت تا تاریخ ${formatPersianDate(result.expiresAt)} هست.`, OwnerKeyboard.menu(), ); await ctx.reply('👑 پنل سازنده ربات', { reply_markup: AdminKeyboard.menu() }); return; } // مسیر قبلی CHANNEL_SUBSCRIPTION if (!ctx.currentOwner) return; const result = await this.paymentRequestService.approve(paymentRequestId, ctx.currentOwner.id); await ctx.answerCallbackQuery({ text: '✅ پرداخت تایید شد' }); await ctx.editMessageCaption({ caption: '✅ این پرداخت تایید شد.' }); const inviteLink = await this.telegramApiService.createSingleUseInviteLink( result.plan.channel.telegramChannelId, `sub-${result.subscription.id}`, ); await this.telegramApiService.sendMessage( result.request.payer.telegramId, [ '✅ پرداختت تایید شد، تبریک می‌گم! 🎉', '', `برای عضویت در کانال «${result.plan.channel.title}» روی این لینک بزن:`, inviteLink, '', `⏳ اعتبار اشتراکت تا تاریخ ${formatPersianDate(result.subscription.expiresAt)} هست.`, ].join('\n'), ); await this.telegramApiService.sendMessageWithKeyboard( result.request.payer.telegramId, '👤 پنل شما', UserKeyboard.menu(), ); await ctx.reply('پنل مدیریت', { reply_markup: OwnerKeyboard.menu() }); } private async askRejectReason(ctx: TelegramContext, data: string): Promise { const paymentRequestId = data.split(':')[2]; await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_REJECT_REASON', data: { pendingPaymentRequestId: paymentRequestId }, }); await ctx.answerCallbackQuery(); await ctx.reply('📝 دلیل رد این پرداخت را بنویسید:'); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\pending-payment-requests.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { InlineKeyboard } from 'grammy'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { PaymentRequestService } from 'src/modules/payment-requests/services/payment-request.service'; import { PaymentApprovalKeyboard } from '../../keyboards/payment-approval.keyboard'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; @OnTelegramCallback( Callback.PAYMENT_REQUEST.RECEIVER_LIST, { pattern: Callback.PAYMENT_REQUEST.RECEIVER_DETAIL, matchType: 'startsWith' }, ) @Injectable() export class PendingPaymentRequestsHandler implements TelegramHandler { private readonly logger = new Logger(PendingPaymentRequestsHandler.name); constructor(private readonly paymentRequestService: PaymentRequestService) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser) return; try { if (data.startsWith(Callback.PAYMENT_REQUEST.RECEIVER_DETAIL)) return this.showDetail(ctx, data); if (data === Callback.PAYMENT_REQUEST.RECEIVER_LIST) return this.showList(ctx); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showList(ctx: TelegramContext): Promise { const requests = ctx.isAdmin ? await this.paymentRequestService.findPendingForAdmin(ctx.currentUser!.id) : await this.paymentRequestService.findPendingForOwner(ctx.currentOwner!.id); if (!requests.length) { await ctx.editMessageText('📥 درخواست پرداخت در انتظار تاییدی وجود ندارد.', { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK), }); return; } const keyboard = new InlineKeyboard(); for (const r of requests) { keyboard .text(`${r.paymentCode} — ${r.amount.toLocaleString()} تومان`, `${Callback.PAYMENT_REQUEST.RECEIVER_DETAIL}:${r.id}`) .row(); } keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); await ctx.editMessageText(`📥 درخواست‌های در انتظار تایید (${requests.length}):`, { reply_markup: keyboard }); } private async showDetail(ctx: TelegramContext, data: string): Promise { const id = data.split(':')[2]; const request = await this.paymentRequestService.findById(id); const belongsToMe = ctx.isAdmin ? request?.receiverAdminUserId === ctx.currentUser!.id : request?.receiverOwnerId === ctx.currentOwner?.id; if (!request || !belongsToMe) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } if (request.status !== 'WaitingApproval') { await ctx.answerCallbackQuery({ text: '❌ این درخواست دیگر در انتظار تایید نیست.', show_alert: true }); await this.showList(ctx); return; } const itemLabel = request.type === 'CHANNEL_SUBSCRIPTION' ? request.plan?.title ?? 'پلن کانال' : request.botPlan?.title ?? 'پلن ربات'; const buyerName = [request.payer.firstName, request.payer.lastName].filter(Boolean).join(' ') || '—'; const caption = [ `🔑 کد پیگیری: ${request.paymentCode}`, `💰 مبلغ: ${request.amount.toLocaleString()} تومان`, `📦 پلن: ${itemLabel}`, request.discountCode ? `🎟 کد تخفیف: ${request.discountCode.code}` : '', '', `👤 نام: ${buyerName}`, `🔗 یوزرنیم: ${request.payer.username ? '@' + request.payer.username : '—'}`, `🆔 آیدی عددی: ${request.payer.telegramId}`, ].filter(Boolean).join('\n'); if (request.receiptFileId) { await ctx.replyWithPhoto(request.receiptFileId, { caption, reply_markup: PaymentApprovalKeyboard.approveReject(request.id), }); await ctx.answerCallbackQuery(); } else { await ctx.editMessageText(caption, { reply_markup: PaymentApprovalKeyboard.approveReject(request.id) }); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\plan-duration-unit.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; @OnTelegramCallback({ pattern: Callback.PLAN.DURATION_UNIT, matchType: 'startsWith' }) @Injectable() export class PlanDurationUnitHandler implements TelegramHandler { private readonly logger = new Logger(PlanDurationUnitHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.session) return; try { const unit = data.split(':')[2] as 'DAY' | 'MONTH' | 'YEAR'; await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_PLAN_DURATION_VALUE', data: { ...ctx.session.data, durationUnit: unit }, }); await ctx.editMessageText(TelegramMessages.ENTER_PLAN_DURATION); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\plan-menu.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { ChannelService } from 'src/modules/channels/services/channel.service'; import { PlanService } from 'src/modules/plans/services/plan.service'; import { PlanKeyboard } from '../../keyboards/plan.keyboard'; import { InlineKeyboard } from 'grammy'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; @OnTelegramCallback( { pattern: Callback.PLAN.MENU, matchType: 'startsWith' }, // 🔧 از exact به startsWith Callback.PLAN.CREATE, Callback.PLAN.LIST, { pattern: Callback.PLAN.SELECT_CHANNEL, matchType: 'startsWith' }, { pattern: Callback.PLAN.EDIT_FIELD, matchType: 'startsWith' }, // 🆕 حتماً قبل از EDIT بذارش { pattern: Callback.PLAN.EDIT, matchType: 'startsWith' }, // 🆕 { pattern: Callback.PLAN.DELETE_CONFIRM, matchType: 'startsWith' }, // 🆕 حتماً قبل از DELETE بذارش { pattern: Callback.PLAN.DELETE, matchType: 'startsWith' }, // 🆕 Callback.PLAN.CONFIRM_CREATE, Callback.PLAN.CANCEL_CREATE, Callback.PLAN.EDIT_CREATE_TITLE, Callback.PLAN.EDIT_CREATE_DURATION, Callback.PLAN.EDIT_CREATE_PRICE, ) @Injectable() export class PlanMenuHandler implements TelegramHandler { private readonly logger = new Logger(PlanMenuHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly channelService: ChannelService, private readonly planService: PlanService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser || !ctx.currentOwner) return; try { if (data.startsWith(Callback.PLAN.SELECT_CHANNEL)) return this.handleSelectChannel(ctx, data); if (data.startsWith(Callback.PLAN.DELETE_CONFIRM)) return this.handleDeleteConfirm(ctx, data); if (data.startsWith(Callback.PLAN.DELETE)) return this.handleDeleteAsk(ctx, data); if (data.startsWith(Callback.PLAN.MENU)) { const page = Number(data.split(':')[2]) || 0; return this.showList(ctx, page); } switch (data) { case Callback.PLAN.CREATE: return this.handleCreateStart(ctx); case Callback.PLAN.CONFIRM_CREATE: return this.finalizeCreate(ctx); case Callback.PLAN.CANCEL_CREATE: await this.sessionService.resetStep(ctx.currentUser!.id); return this.showList(ctx, 0); } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showList(ctx: TelegramContext, page: number, asNewMessage = false): Promise { const pageSize = 10; const [plans, total] = await Promise.all([ this.planService.getOwnerPlansPaginated(ctx.currentOwner!.id, page, pageSize), this.planService.countByOwner(ctx.currentOwner!.id), ]); const text = plans.length ? '💳 پلن‌های شما:' : 'هنوز پلنی ثبت نکرده‌اید.'; const hasNextPage = (page + 1) * pageSize < total; const keyboard = PlanKeyboard.list(plans, page, hasNextPage); if (asNewMessage) { await ctx.reply(text, { reply_markup: keyboard }); } else { await ctx.editMessageText(text, { reply_markup: keyboard }); } } private async finalizeCreate(ctx: TelegramContext): Promise { const { channelId, title, durationUnit, durationValue, price } = ctx.session!.data; if (!channelId || !title || !durationUnit || !durationValue || !price) { await ctx.editMessageText(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser!.id); await this.showList(ctx, 0, true); return; } await this.planService.create({ channelId, title, price, durationUnit, durationValue }); await this.sessionService.resetStep(ctx.currentUser!.id); await ctx.editMessageText(TelegramMessages.PLAN_CREATED); await this.showList(ctx, 0, true); } // بعد private async handleCreateStart(ctx: TelegramContext): Promise { const channels = await this.channelService.getOwnerChannels(ctx.currentOwner!.id); if (!channels.length) { await ctx.editMessageText( '❌ ابتدا باید حداقل یک کانال ثبت کنید.', { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, `${Callback.PLAN.MENU}:0`) }, ); return; } await ctx.editMessageText('📺 پلن برای کدام کانال ساخته شود؟', { reply_markup: PlanKeyboard.selectChannel(channels), }); } private async handleSelectChannel(ctx: TelegramContext, data: string): Promise { const channelId = data.split(':')[2]; await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_PLAN_TITLE', data: { channelId }, }); await ctx.editMessageText(TelegramMessages.ENTER_PLAN_TITLE); } private async handleList(ctx: TelegramContext): Promise { const plans = await this.planService.getOwnerPlans(ctx.currentOwner!.id); if (!plans.length) { await ctx.editMessageText('📋 هنوز پلنی ثبت نشده است.', { reply_markup: PlanKeyboard.backToList() }); return; } await ctx.editMessageText('📋 پلن‌های شما:', { reply_markup: PlanKeyboard.viewList(plans) }); } private async handleEditStart(ctx: TelegramContext, data: string): Promise { const planId = data.split(':')[2]; const plan = await this.planService.findByIdForOwner(planId, ctx.currentOwner!.id); if (!plan) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await ctx.editMessageText(TelegramMessages.PLAN_EDIT_SELECT_FIELD, { reply_markup: PlanKeyboard.editFields(planId), }); } private async handleEditField(ctx: TelegramContext, data: string): Promise { const [, , planId, field] = data.split(':'); const plan = await this.planService.findByIdForOwner(planId, ctx.currentOwner!.id); if (!plan) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_PLAN_EDIT_VALUE', data: { planId, editField: field as 'title' | 'price' | 'durationValue' }, }); const prompt = field === 'title' ? TelegramMessages.ENTER_NEW_PLAN_TITLE : field === 'price' ? TelegramMessages.ENTER_NEW_PLAN_PRICE : TelegramMessages.ENTER_NEW_PLAN_DURATION; await ctx.editMessageText(prompt); } private async handleDeleteAsk(ctx: TelegramContext, data: string): Promise { const planId = data.split(':')[2]; const plan = await this.planService.findByIdForOwner(planId, ctx.currentOwner!.id); if (!plan) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await ctx.editMessageText( `❗️ مطمئنی می‌خوای پلن «${plan.title}» رو حذف کنی؟`, { reply_markup: PlanKeyboard.deleteConfirm(planId) }, ); } // 🆕 حذف قطعی بعد از تایید private async handleDeleteConfirm(ctx: TelegramContext, data: string): Promise { const planId = data.split(':')[2]; const plan = await this.planService.findByIdForOwner(planId, ctx.currentOwner!.id); if (!plan) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await this.planService.remove(planId); await ctx.answerCallbackQuery({ text: TelegramMessages.PLAN_DELETED }); await this.showList(ctx, 0); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\stats.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { StatsService } from 'src/modules/stats/services/stats.service'; import { OwnerKeyboard } from '../../keyboards/owner.keyboard'; @OnTelegramCallback( Callback.OWNER.STATS, { pattern: Callback.OWNER.STATS_REVENUE, matchType: 'startsWith' }, ) @Injectable() export class StatsHandler implements TelegramHandler { private readonly logger = new Logger(StatsHandler.name); constructor(private readonly statsService: StatsService) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentOwner) return; try { if (data.startsWith(Callback.OWNER.STATS_REVENUE)) return this.showRevenue(ctx, data); if (data === Callback.OWNER.STATS) return this.showOverview(ctx); } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showOverview(ctx: TelegramContext): Promise { const ownerId = ctx.currentOwner!.id; const { overview, renewalRate, breakdown } = await this.statsService.getOwnerDashboard(ownerId); // 🔧 یک متد به‌جای ۳ تا Promise.all جدا const breakdownText = breakdown.length ? breakdown.map((b) => ` • ${b.title}: ${b.count} فروش — ${b.revenue.toLocaleString()} تومان`).join('\n') : ' موردی ثبت نشده است.'; const text = [ TelegramMessages.STATS_HEADER, `🟢 اعضای فعال: ${overview.activeCount}`, `🔴 اعضای منقضی: ${overview.expiredCount}`, `🆕 عضو جدید (۷ روز اخیر): ${overview.newThisWeek}`, `🔄 نرخ تمدید: ${renewalRate}٪`, '', '💳 فروش بر اساس پلن:', breakdownText, '', 'برای مشاهده‌ی درآمد بازه‌ای، یکی از گزینه‌های زیر را انتخاب کنید:', ].join('\n'); await ctx.editMessageText(text, { reply_markup: OwnerKeyboard.statsOverview() }); } private async showRevenue(ctx: TelegramContext, data: string): Promise { const range = data.split(':')[2] as 'daily' | 'weekly' | 'monthly' | 'yearly'; const ownerId = ctx.currentOwner!.id; const [revenue, performance] = await Promise.all([ this.statsService.getRevenue(ownerId, range), this.statsService.getChannelPerformanceReport(ownerId, range), ]); const rangeFa = { daily: 'امروز', weekly: 'این هفته', monthly: 'این ماه', yearly: 'امسال' }[range]; const channelLines = [...performance.channels] .sort((a, b) => b.revenue - a.revenue) .map((c, i) => { const medal = i === 0 && c.revenue > 0 ? '🥇 ' : ''; return [ `${medal}📺 ${c.title}`, ` 💰 درآمد: ${c.revenue.toLocaleString()} تومان — 🛒 فروش: ${c.salesCount}`, ` 🟢 فعال: ${c.activeCount} | 🔴 منقضی: ${c.expiredCount} | 🔄 نرخ تمدید: ${c.renewalRate}٪`, ].join('\n'); }) .join('\n\n'); const bestWorstText = performance.best && performance.worst ? `\n\n🏆 پرفروش‌ترین کانال: ${performance.best.title}\n📉 کم‌فروش‌ترین کانال: ${performance.worst.title}` : ''; await ctx.editMessageText( [ `📊 گزارش ${rangeFa}`, `مجموع درآمد: ${revenue.totalRevenue.toLocaleString()} تومان`, `تعداد فروش کل: ${revenue.salesCount}`, '', '📈 عملکرد به‌تفکیک کانال:', channelLines || 'کانالی ثبت نشده است.', bestWorstText, ].join('\n'), { reply_markup: OwnerKeyboard.revenueDetail() }, ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\subscription-menu.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { SubscriptionKeyboard } from '../../keyboards/subscription.keyboard'; import { ChannelService } from 'src/modules/channels/services/channel.service'; import { PlanService } from 'src/modules/plans/services/plan.service'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { SubscriptionService } from 'src/modules/subscriptions/services/subscription.service'; import { formatPersianDate } from 'src/common/utils/date.util'; import { TelegramApiService } from '../../services/telegram-api.service'; import { InlineKeyboard } from 'grammy'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; import { PaymentRequestService } from 'src/modules/payment-requests/services/payment-request.service'; import { BankCardService } from 'src/modules/bank-cards/services/bank-card.service'; import { ChannelBanService } from 'src/modules/channel-bans/services/channel-ban.service'; @OnTelegramCallback( { pattern: Callback.SUBSCRIPTION.MENU, matchType: 'startsWith' }, // 🔧 حالا پارامتر صفحه داره، خودش لیسته Callback.SUBSCRIPTION.BROWSE_CHANNELS, Callback.SUBSCRIPTION.ENTER_DISCOUNT, Callback.SUBSCRIPTION.SKIP_DISCOUNT, { pattern: Callback.SUBSCRIPTION.BROWSE_PLANS, matchType: 'startsWith' }, { pattern: Callback.SUBSCRIPTION.SELECT_PLAN, matchType: 'startsWith' }, { pattern: Callback.SUBSCRIPTION.RENEW, matchType: 'startsWith' }, { pattern: Callback.SUBSCRIPTION.UPGRADE, matchType: 'startsWith' }, { pattern: Callback.SUBSCRIPTION.SELECT_UPGRADE_PLAN, matchType: 'startsWith' }, { pattern: Callback.SUBSCRIPTION.GET_INVITE_LINK, matchType: 'startsWith' }, { pattern: Callback.SUBSCRIPTION.CANCEL_PAYMENT, matchType: 'startsWith' }, ) @Injectable() export class SubscriptionMenuHandler implements TelegramHandler { private readonly logger = new Logger(SubscriptionMenuHandler.name); constructor( private readonly channelService: ChannelService, private readonly planService: PlanService, private readonly sessionService: TelegramSessionService, private readonly paymentRequestService: PaymentRequestService, private readonly bankCardService: BankCardService, private readonly subscriptionService: SubscriptionService, private readonly telegramApiService: TelegramApiService, private readonly channelBanService: ChannelBanService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser) return; try { if (data.startsWith(Callback.SUBSCRIPTION.BROWSE_PLANS)) return this.showPlans(ctx, data); if (data.startsWith(Callback.SUBSCRIPTION.SELECT_PLAN)) return this.selectPlan(ctx, data); if (data.startsWith(Callback.SUBSCRIPTION.RENEW)) return this.renew(ctx, data); if (data.startsWith(Callback.SUBSCRIPTION.UPGRADE)) return this.showUpgradeOptions(ctx, data); if (data.startsWith(Callback.SUBSCRIPTION.SELECT_UPGRADE_PLAN)) return this.selectUpgradePlan(ctx, data); if (data.startsWith(Callback.SUBSCRIPTION.GET_INVITE_LINK)) return this.getInviteLink(ctx, data); if (data.startsWith(Callback.SUBSCRIPTION.CANCEL_PAYMENT)) return this.cancelPayment(ctx, data); if (data === Callback.SUBSCRIPTION.ENTER_DISCOUNT) return this.enterDiscount(ctx); if (data === Callback.SUBSCRIPTION.SKIP_DISCOUNT) return this.skipDiscount(ctx); // 🔧 MENU حالا پارامتر صفحه داره و خودش لیست رو نشون می‌ده if (data.startsWith(Callback.SUBSCRIPTION.MENU)) { const page = Number(data.split(':')[2]) || 0; return this.showList(ctx, page); } if (data === Callback.SUBSCRIPTION.BROWSE_CHANNELS) { return this.showChannels(ctx); } } catch (error) { this.logger.error(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } private async showList(ctx: TelegramContext, page: number, asNewMessage = false): Promise { const pageSize = 10; const [subs, total] = await Promise.all([ this.subscriptionService.findActiveByUserPaginated(ctx.currentUser!.id, page, pageSize), this.subscriptionService.countActiveByUser(ctx.currentUser!.id), ]); if (!subs.length && page === 0) { const text = asNewMessage ? undefined : undefined; // برای وضوح؛ پایین‌تر استفاده می‌شه const keyboard = new InlineKeyboard().text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); if (asNewMessage) { await ctx.reply(TelegramMessages.NO_ACTIVE_SUBSCRIPTIONS, { reply_markup: keyboard }); } else { await ctx.editMessageText(TelegramMessages.NO_ACTIVE_SUBSCRIPTIONS, { reply_markup: keyboard }); } return; } const hasNextPage = (page + 1) * pageSize < total; const keyboard = SubscriptionKeyboard.list( subs.map((s) => ({ id: s.id, channelTitle: s.channel.title })), page, hasNextPage, ); if (asNewMessage) { await ctx.reply('📦 اشتراک‌های شما:', { reply_markup: keyboard }); } else { await ctx.editMessageText('📦 اشتراک‌های شما:', { reply_markup: keyboard }); } } private async showChannels(ctx: TelegramContext): Promise { const channels = await this.channelService.listSellableChannels(); if (!channels.length) { await ctx.editMessageText(TelegramMessages.NO_SELLABLE_CHANNELS, { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, `${Callback.SUBSCRIPTION.MENU}:0`), }); return; } await ctx.editMessageText('📺 برای کدام کانال می‌خواهید اشتراک بخرید؟', { reply_markup: SubscriptionKeyboard.channelList(channels), }); } private async showPlans(ctx: TelegramContext, data: string): Promise { const channelId = data.split(':')[2]; const plans = await this.planService.getChannelPlansForPurchase(channelId); if (!plans.length) { await ctx.editMessageText('❌ فعلاً پلنی برای این کانال تعریف نشده است.', { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, Callback.SUBSCRIPTION.BROWSE_CHANNELS), }); return; } await ctx.editMessageText('💳 یک پلن انتخاب کنید:', { reply_markup: SubscriptionKeyboard.planList(plans), }); } private async selectPlan(ctx: TelegramContext, data: string): Promise { const planId = data.split(':')[2]; const plan = await this.planService.findById(planId); if (!plan) { await ctx.editMessageText(TelegramMessages.PLAN_NOT_FOUND); return; } const isBanned = await this.channelBanService.isBanned(plan.channelId, ctx.currentUser!.id); if (isBanned) { await ctx.editMessageText('⛔ شما دسترسی خرید اشتراک این کانال را ندارید.'); return; } const activeSub = await this.subscriptionService.findActiveByUserAndChannel( ctx.currentUser!.id, plan.channelId, ); if (activeSub) { await ctx.editMessageText( [ '⚠️ شما در حال حاضر یک اشتراک فعال برای این کانال دارید.', `اعتبار فعلی تا ${formatPersianDate(activeSub.expiresAt)} است.`, 'برای تمدید یا ارتقای پلن از «📦 اشتراک‌های من» اقدام کنید.', ].join('\n'), { reply_markup: new InlineKeyboard() .text('📦 اشتراک‌های من', `${Callback.SUBSCRIPTION.MENU}:0`) // 🔧 .row() .text(KeyboardConstants.BACK, Callback.SUBSCRIPTION.BROWSE_CHANNELS), }, ); return; } await this.promptDiscount(ctx, { kind: 'CHANNEL_PLAN', planId }); } // بعد async startPaymentRequestFlow( ctx: TelegramContext, planId: string, override?: { subscriptionId?: string; renewOrUpgrade?: 'renew' | 'upgrade'; discountCodeId?: string; finalAmount?: number; }, ): Promise { const plan = await this.planService.findByIdWithChannelOwner(planId); if (!plan) { await ctx.reply(TelegramMessages.PLAN_NOT_FOUND); return; } const card = await this.bankCardService.getDefaultForOwner(plan.channel.ownerId); if (!card) { // 🔧 اصل ۷ سند await ctx.reply( '❌ صاحب این کانال هنوز کارت بانکی ثبت نکرده است. لطفاً بعداً تلاش کنید.', { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK) }, ); return; } let subscriptionId = override?.subscriptionId; let renewOrUpgrade = override?.renewOrUpgrade; if (!renewOrUpgrade) { const existing = await this.subscriptionService.findActiveByUserAndChannel( ctx.currentUser!.id, plan.channelId, ); if (existing) { subscriptionId = existing.id; renewOrUpgrade = 'renew'; } } const amount = override?.finalAmount ?? plan.price; const paymentRequest = await this.paymentRequestService.createForChannelPlan({ payerId: ctx.currentUser!.id, planId, amount, receiverOwnerId: plan.channel.ownerId, bankCardId: card.id, subscriptionId, renewOrUpgrade, discountCodeId: override?.discountCodeId, }); await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_PAYMENT_RECEIPT', data: { pendingPaymentRequestId: paymentRequest.id }, }); await ctx.reply( [ `💳 مبلغ قابل پرداخت: ${amount.toLocaleString()} تومان`, override?.discountCodeId ? '🎟 کد تخفیفت اعمال شد، دمت گرم!' : '', '', `به نام: ${card.holderName}`, 'برای کپی شماره کارت، روی دکمه‌ی زیر بزن 👇', '', `🔑 کد پیگیریت: ${paymentRequest.paymentCode}`, 'اگه امکانش هست، این کد رو در توضیحات انتقال بنویس تا سریع‌تر پیدات کنم.', '', '📸 پرداخت که انجام شد، عکس فیشش رو همین‌جا برام بفرست تا برای تایید بفرستمش.', ].filter(Boolean).join('\n'), { reply_markup: new InlineKeyboard() .copyText(`💳 ${card.cardNumber}`, card.cardNumber) .row() .text('❌ انصراف از خرید', `${Callback.SUBSCRIPTION.CANCEL_PAYMENT}:${paymentRequest.id}`), }, ); } // 🆕 پرسیدن «کد تخفیف داری؟» قبل از ساخت درخواست پرداخت private async promptDiscount( ctx: TelegramContext, purchase: { kind: 'CHANNEL_PLAN'; planId: string; subscriptionId?: string; renewOrUpgrade?: 'renew' | 'upgrade' }, ): Promise { await this.sessionService.update(ctx.currentUser!.id, { step: 'IDLE', data: { ...ctx.session?.data, pendingPurchase: purchase }, }); await ctx.editMessageText(TelegramMessages.ASK_DISCOUNT_CODE, { reply_markup: new InlineKeyboard() .text('🎟 دارم', Callback.SUBSCRIPTION.ENTER_DISCOUNT) .text('رد شدن، بدون کد', Callback.SUBSCRIPTION.SKIP_DISCOUNT) .row() .text(KeyboardConstants.BACK, `${Callback.SUBSCRIPTION.MENU}:0`), // 🔧 }); } private async enterDiscount(ctx: TelegramContext): Promise { await this.sessionService.update(ctx.currentUser!.id, { step: 'WAITING_REDEEM_DISCOUNT_CODE', data: ctx.session!.data, }); await ctx.editMessageText(TelegramMessages.ENTER_DISCOUNT_CODE); } private async skipDiscount(ctx: TelegramContext): Promise { const pending = ctx.session?.data.pendingPurchase; if (!pending?.planId) { await ctx.editMessageText(TelegramMessages.PLAN_FLOW_EXPIRED); return; } await this.startPaymentRequestFlow(ctx, pending.planId, { subscriptionId: pending.subscriptionId, renewOrUpgrade: pending.renewOrUpgrade, }); } private async cancelPayment(ctx: TelegramContext, data: string): Promise { const paymentRequestId = data.split(':')[2]; const ok = await this.paymentRequestService.cancel(paymentRequestId, ctx.currentUser!.id); if (!ok) { await ctx.answerCallbackQuery({ text: '❌ این درخواست دیگر قابل لغو نیست (شاید فیش ارسال شده).', show_alert: true, }); return; } await this.sessionService.resetStep(ctx.currentUser!.id); await ctx.editMessageText('❌ درخواست خرید لغو شد.', { reply_markup: new InlineKeyboard().text('📦 اشتراک‌های من', `${Callback.SUBSCRIPTION.MENU}:0`), // 🔧 }); } private async getInviteLink(ctx: TelegramContext, data: string): Promise { const subscriptionId = data.split(':')[2]; const subscription = await this.subscriptionService.findById(subscriptionId); if (!subscription || subscription.userId !== ctx.currentUser!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } if (subscription.status !== 'ACTIVE') { await ctx.answerCallbackQuery({ text: TelegramMessages.SUBSCRIPTION_NOT_ACTIVE, show_alert: true }); return; } const link = await this.telegramApiService.createSingleUseInviteLink( subscription.channel.telegramChannelId, `sub-${subscription.id}`, ); await ctx.answerCallbackQuery(); await ctx.reply(`🔗 لینک عضویت شما در کانال «${subscription.channel.title}»:\n${link}`); } // private async showMySubscriptions(ctx: TelegramContext, page: number): Promise { // const pageSize = 10; // const [subs, total] = await Promise.all([ // this.subscriptionService.findActiveByUserPaginated(ctx.currentUser!.id, page, pageSize), // this.subscriptionService.countActiveByUser(ctx.currentUser!.id), // ]); // if (!subs.length && page === 0) { // await ctx.reply(TelegramMessages.NO_ACTIVE_SUBSCRIPTIONS, { // reply_markup: SubscriptionKeyboard.menu(), // }); // return; // } // const hasNextPage = (page + 1) * pageSize < total; // await ctx.reply('👤 اشتراک‌های من:', { // reply_markup: SubscriptionKeyboard.mySubscriptionsList( // subs.map((s) => ({ id: s.id, channelTitle: s.channel.title })), // page, // hasNextPage, // ), // }); // } // 🔧 مورد ۶: دیگه سراغ کد تخفیف نمی‌ره، مستقیم Payment Request با renewOrUpgrade='renew' private async renew(ctx: TelegramContext, data: string): Promise { const subscriptionId = data.split(':')[2]; const subscription = await this.subscriptionService.findById(subscriptionId); if (!subscription || subscription.userId !== ctx.currentUser!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await this.promptDiscount(ctx, { kind: 'CHANNEL_PLAN', planId: subscription.planId, subscriptionId: subscription.id, renewOrUpgrade: 'renew', }); } private async showUpgradeOptions(ctx: TelegramContext, data: string): Promise { const subscriptionId = data.split(':')[2]; const subscription = await this.subscriptionService.findById(subscriptionId); if (!subscription || subscription.userId !== ctx.currentUser!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } const allPlans = await this.planService.getChannelPlansForPurchase(subscription.channelId); const otherPlans = allPlans.filter((p) => p.id !== subscription.planId); if (!otherPlans.length) { // 🔧 اصل ۷ سند: پیام + برگشت به لیست، نه Dead-end await ctx.answerCallbackQuery({ text: TelegramMessages.NO_OTHER_PLANS_FOR_UPGRADE, show_alert: true, }); return this.showList(ctx, 0); } await ctx.editMessageText(`⬆️ ارتقا پلن «${subscription.channel.title}»\nپلن جدید را انتخاب کنید:`, { reply_markup: SubscriptionKeyboard.upgradePlanList(subscriptionId, otherPlans), }); } // 🔧 مورد ۶: مستقیم Payment Request با renewOrUpgrade='upgrade' private async selectUpgradePlan(ctx: TelegramContext, data: string): Promise { const [, , subscriptionId, newPlanId] = data.split(':'); const subscription = await this.subscriptionService.findById(subscriptionId); if (!subscription || subscription.userId !== ctx.currentUser!.id) { await ctx.answerCallbackQuery({ text: TelegramMessages.ACCESS_DENIED, show_alert: true }); return; } await this.promptDiscount(ctx, { kind: 'CHANNEL_PLAN', planId: newPlanId, subscriptionId: subscription.id, renewOrUpgrade: 'upgrade', }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\callbacks\user-menu.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCallback } from '../../decorators/telegram-callback.decorator'; import { Callback } from '../../constants/callback'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { InlineKeyboard } from 'grammy'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; import { UserKeyboard } from '../../keyboards/user.keyboard'; import { BotSubscriptionService } from 'src/modules/bot-subscriptions/services/bot-subscription.service'; import { BotSubscriptionKeyboard } from '../../keyboards/bot-subscription.keyboard'; @OnTelegramCallback( Callback.USER.MENU, Callback.USER.CREATE_CHANNEL, ) @Injectable() export class UserMenuHandler implements TelegramHandler { private readonly logger = new Logger(UserMenuHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly botSubscriptionService: BotSubscriptionService, ) { } async execute(ctx: TelegramContext): Promise { const data = ctx.callbackQuery?.data; if (!data || !ctx.currentUser) return; try { switch (data) { case Callback.USER.MENU: await this.sessionService.resetStep(ctx.currentUser.id); await ctx.editMessageText('👤 پنل شما', { reply_markup: UserKeyboard.menu() }); return; case Callback.USER.CREATE_CHANNEL: { const hasActiveBotSub = await this.botSubscriptionService.isActive(ctx.currentOwner!.id); if (!hasActiveBotSub) { await ctx.editMessageText( '⛔ برای ثبت کانال، ابتدا باید اشتراک ربات را خریداری کنید.', { reply_markup: BotSubscriptionKeyboard.renewButton() }, ); return; } const guideMessageId = ctx.callbackQuery?.message?.message_id; await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_CHANNEL_ADMIN_CONFIRM', data: { channelGuideMessageId: guideMessageId }, }); await ctx.editMessageText(TelegramMessages.ENTER_CHANNEL_ID, { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, Callback.USER.MENU), }); return; } } } catch (error) { this.logger.log(error); await ctx.answerCallbackQuery({ text: TelegramMessages.SOMETHING_WENT_WRONG, show_alert: true }); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\commands\add-channel.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCommand } from '../../decorators/telegram-command.decorator'; import { TelegramCommands } from '../../constants/command'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { InlineKeyboard } from 'grammy'; import { KeyboardConstants } from '../../keyboards/keyboard.constants'; import { Callback } from '../../constants/callback'; import { BotSubscriptionService } from 'src/modules/bot-subscriptions/services/bot-subscription.service'; // 🆕 import { BotSubscriptionKeyboard } from '../../keyboards/bot-subscription.keyboard'; // 🆕 @OnTelegramCommand(TelegramCommands.ADD_CHANNEL) @Injectable() export class AddChannelHandler implements TelegramHandler { private readonly logger = new Logger(AddChannelHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly botSubscriptionService: BotSubscriptionService, // 🆕 ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser) { await ctx.reply(TelegramMessages.ACCESS_DENIED); return; } try { // 🆕 طبق سند بخش ۱۹ بند ۵: بدون اشتراک ربات فعال، ثبت کانال جدید ممنوع const hasActiveBotSub = await this.botSubscriptionService.isActive(ctx.currentOwner!.id); if (!hasActiveBotSub) { await ctx.reply( '⛔ برای ثبت کانال، ابتدا باید اشتراک ربات را خریداری کنید.', { reply_markup: BotSubscriptionKeyboard.renewButton() }, ); return; } const sentMessage = await ctx.reply(TelegramMessages.ENTER_CHANNEL_ID, { reply_markup: new InlineKeyboard().text(KeyboardConstants.BACK, Callback.USER.MENU), }); await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_CHANNEL_ADMIN_CONFIRM', data: { channelGuideMessageId: sentMessage.message_id }, }); } catch (error) { this.logger.error('Failed to start add-channel flow', error); await ctx.reply(TelegramMessages.SOMETHING_WENT_WRONG); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\commands\ping.handler.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCommand } from '../../decorators/telegram-command.decorator'; @OnTelegramCommand('ping') @Injectable() export class PingHandler implements TelegramHandler { async execute(ctx: TelegramContext): Promise { await ctx.reply('pong ✅ زیرساخت تلگرام کار می‌کنه'); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\commands\start.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramCommand } from '../../decorators/telegram-command.decorator'; import { TelegramCommands } from '../../constants/command'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { UserKeyboard } from '../../keyboards/user.keyboard'; import { OwnerKeyboard } from '../../keyboards/owner.keyboard'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; import { BotSubscriptionKeyboard } from '../../keyboards/bot-subscription.keyboard'; import { ChannelService } from 'src/modules/channels/services/channel.service'; import { PlanService } from 'src/modules/plans/services/plan.service'; import { SubscriptionKeyboard } from '../../keyboards/subscription.keyboard'; @OnTelegramCommand(TelegramCommands.START) @Injectable() export class StartHandler implements TelegramHandler { private readonly logger = new Logger(StartHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly channelService: ChannelService, private readonly planService: PlanService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser) { await ctx.reply(TelegramMessages.ACCESS_DENIED); return; } try { await this.sessionService.reset(ctx.currentUser.id); const payload = ctx.match?.toString().trim(); if (payload?.startsWith('sub_')) { return this.startChannelSubscriptionFlow(ctx, payload.slice('sub_'.length)); } switch (ctx.role) { case 'ADMIN': await ctx.reply(`👑 سلام ${ctx.currentUser.firstName ?? ''}! خوش اومدی به پنل سازنده‌ی ربات.`, { reply_markup: AdminKeyboard.menu(), // 🔧 قبلاً placeholder بود }); return; case 'OWNER': if (!ctx.botSubscriptionActive) { // 🆕 چک جدا از role await ctx.reply(TelegramMessages.BOT_SUBSCRIPTION_EXPIRED, { reply_markup: BotSubscriptionKeyboard.renewButton(), }); return; } await ctx.reply(`سلام ${ctx.currentOwner?.displayName ?? ''} 👋 خوش برگشتی به پنل مدیریت کانالت!`, { reply_markup: OwnerKeyboard.menu(), }); return; default: await ctx.reply('سلام 👋 خوش اومدی! از منوی پایین می‌تونی اشتراک کانال‌های VIP رو بخری یا کانال خودت رو ثبت کنی.', { reply_markup: UserKeyboard.menu() }); return; } } catch (error) { this.logger.error('Failed to execute start command', error); await ctx.reply(TelegramMessages.SOMETHING_WENT_WRONG); } } private async startChannelSubscriptionFlow(ctx: TelegramContext, channelId: string): Promise { const channel = await this.channelService.findById(channelId); if (!channel) { await ctx.reply('❌ این لینک اشتراک دیگر معتبر نیست.'); return; } const plans = await this.planService.getChannelPlansForPurchase(channelId); if (!plans.length) { await ctx.reply('❌ فعلاً پلنی برای این کانال تعریف نشده است.'); return; } await ctx.reply(`📺 ${channel.title}\n💳 یک پلن انتخاب کنید:`, { reply_markup: SubscriptionKeyboard.planList(plans), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-admin-discount-code.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { DiscountService } from 'src/modules/discounts/services/discount.service'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; @OnTelegramMessageStep('WAITING_ADMIN_DISCOUNT_CODE') @Injectable() export class WaitingAdminDiscountCodeHandler implements TelegramHandler { private readonly logger = new Logger(WaitingAdminDiscountCodeHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly discountService: DiscountService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.isAdmin) return; // 🔒 گارد مستقل const code = ctx.messageText?.trim().toUpperCase(); if (!code) { await ctx.reply(TelegramMessages.ENTER_NEW_DISCOUNT_CODE); return; } const existing = await this.discountService.findByCode(code); if (existing) { await ctx.reply(TelegramMessages.DISCOUNT_CODE_DUPLICATE); return; } // 🔑 توجه: step به IDLE می‌رود چون قدم بعدی (انتخاب نوع تخفیف) // با کالبک انجام می‌شود، نه با پیام متنی دیگر. await this.sessionService.update(ctx.currentUser.id, { step: 'IDLE', data: { ...ctx.session.data, newDiscountCode: code }, }); await ctx.reply(TelegramMessages.SELECT_DISCOUNT_TYPE, { reply_markup: AdminKeyboard.discountSelectType(), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-admin-discount-expiry.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; @OnTelegramMessageStep('WAITING_ADMIN_DISCOUNT_EXPIRY_DAYS') @Injectable() export class WaitingAdminDiscountExpiryHandler implements TelegramHandler { private readonly logger = new Logger(WaitingAdminDiscountExpiryHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.isAdmin) return; // 🔒 گارد مستقل const days = Number(ctx.messageText); if (!days || days <= 0) { await ctx.reply(TelegramMessages.INVALID_DISCOUNT_VALUE); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'IDLE', data: { ...ctx.session.data, newDiscountExpiresInDays: days }, }); const { newDiscountCode, newDiscountScope, newDiscountType, newDiscountValue, newDiscountMaxUsage } = ctx.session.data; const scopeFa = newDiscountScope === 'CHANNEL_PLAN' ? 'خرید اشتراک کانال (همه کانال‌ها)' : 'خرید اشتراک ربات'; const typeFa = newDiscountType === 'PERCENTAGE' ? 'درصدی' : 'مبلغ ثابت'; const valueFa = newDiscountType === 'PERCENTAGE' ? `${newDiscountValue}٪` : `${newDiscountValue?.toLocaleString()} تومان`; await ctx.reply( [ '📋 خلاصه کد تخفیف سراسری:', `کد: ${newDiscountCode}`, `کاربرد: ${scopeFa}`, `نوع: ${typeFa}`, `مقدار: ${valueFa}`, `سقف استفاده: ${newDiscountMaxUsage ? newDiscountMaxUsage : 'نامحدود'}`, `انقضا: ${days} روز دیگر`, '', 'آیا تایید می‌کنید؟', ].join('\n'), { reply_markup: AdminKeyboard.discountConfirm() }, ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-admin-discount-max-usage.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; @OnTelegramMessageStep('WAITING_ADMIN_DISCOUNT_MAX_USAGE') @Injectable() export class WaitingAdminDiscountMaxUsageHandler implements TelegramHandler { private readonly logger = new Logger(WaitingAdminDiscountMaxUsageHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.isAdmin) return; // 🔒 گارد مستقل const maxUsage = Number(ctx.messageText); if (!maxUsage || maxUsage <= 0) { await ctx.reply(TelegramMessages.INVALID_DISCOUNT_VALUE); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_ADMIN_DISCOUNT_EXPIRY_DAYS', data: { ...ctx.session.data, newDiscountMaxUsage: maxUsage }, }); await ctx.reply(TelegramMessages.ASK_DISCOUNT_EXPIRY, { reply_markup: AdminKeyboard.discountSkipExpiry(), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-admin-discount-value.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; @OnTelegramMessageStep('WAITING_ADMIN_DISCOUNT_VALUE') @Injectable() export class WaitingAdminDiscountValueHandler implements TelegramHandler { private readonly logger = new Logger(WaitingAdminDiscountValueHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.isAdmin) return; // 🔒 گارد مستقل const value = Number(ctx.messageText?.replace(/,/g, '')); const type = ctx.session.data.newDiscountType; if (!value || value <= 0 || (type === 'PERCENTAGE' && value > 100)) { await ctx.reply(TelegramMessages.INVALID_DISCOUNT_VALUE); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_ADMIN_DISCOUNT_MAX_USAGE', data: { ...ctx.session.data, newDiscountValue: value }, }); await ctx.reply(TelegramMessages.ASK_DISCOUNT_MAX_USAGE, { reply_markup: AdminKeyboard.discountSkipMaxUsage(), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-admin-user-search-query.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { UsersService } from 'src/modules/users/services/users.service'; @OnTelegramMessageStep('WAITING_ADMIN_USER_SEARCH_QUERY') @Injectable() export class WaitingAdminUserSearchQueryHandler implements TelegramHandler { private readonly logger = new Logger(WaitingAdminUserSearchQueryHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly usersService: UsersService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.isAdmin) return; // 🔒 const query = ctx.messageText?.trim(); if (!query) return; const results = await this.usersService.searchGlobal(query); await this.sessionService.resetStep(ctx.currentUser.id); if (!results.length) { await ctx.reply('❌ نتیجه‌ای پیدا نشد.'); return; } const text = results .map((u) => `👤 ${u.firstName ?? '—'} (@${u.username ?? '—'}) — ${u.telegramId} — نقش: ${u.role}`) .join('\n'); await ctx.reply(`🔍 نتایج جستجو:\n\n${text}`); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-ban-duration.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramSessionService } from '../../services/telegram-session.service'; @OnTelegramMessageStep('WAITING_BAN_DURATION_DAYS') @Injectable() export class WaitingBanDurationHandler implements TelegramHandler { private readonly logger = new Logger(WaitingBanDurationHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const days = Number(ctx.messageText); if (!days || days <= 0) { await ctx.reply('❌ عدد روز معتبر نیست. دوباره وارد کنید:'); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BAN_REASON', data: { ...ctx.session.data, banDurationDays: days }, }); await ctx.reply('📝 دلیل بن را بنویسید:'); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-ban-reason.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { OwnerService } from 'src/modules/owners/services/owner.service'; import { ChannelBanService } from 'src/modules/channel-bans/services/channel-ban.service'; import { TelegramApiService } from '../../services/telegram-api.service'; import { UsersService } from 'src/modules/users/services/users.service'; @OnTelegramMessageStep('WAITING_BAN_REASON') @Injectable() export class WaitingBanReasonHandler implements TelegramHandler { private readonly logger = new Logger(WaitingBanReasonHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly ownerService: OwnerService, private readonly channelBanService: ChannelBanService, private readonly usersService: UsersService, private readonly telegramApiService: TelegramApiService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const reason = ctx.messageText?.trim(); const { banTargetType, banTargetId, banChannelId, banDurationDays } = ctx.session.data; if (!reason) { await ctx.reply('لطفاً دلیل را بنویسید.'); return; } if (!banTargetType || !banTargetId) { await ctx.reply('⌛ اطلاعات منقضی شده. دوباره شروع کنید.'); await this.sessionService.resetStep(ctx.currentUser.id); return; } const bannedUntil = banDurationDays ? new Date(Date.now() + banDurationDays * 24 * 60 * 60 * 1000) : null; // null = دائم await this.sessionService.resetStep(ctx.currentUser.id); if (banTargetType === 'OWNER') { if (!ctx.isAdmin) { await ctx.reply('⛔ دسترسی ندارید.'); return; } await this.ownerService.ban(banTargetId, banDurationDays ? 'TEMPORARY' : 'PERMANENT', reason, bannedUntil); const owner = await this.ownerService.findByIdWithUser(banTargetId); await ctx.reply('✅ صاحب کانال بن شد.'); if (owner) { await this.telegramApiService.sendMessage( owner.user.telegramId, `⛔ حساب شما توسط مدیریت محدود شد.\nدلیل: ${reason}${bannedUntil ? `\nتا: ${bannedUntil.toLocaleDateString('fa-IR')}` : ' (دائم)'}`, ).catch(() => undefined); } return; } // banTargetType === 'USER' — بن کاربر مختص یک کانال، توسط Owner if (!banChannelId || !ctx.currentOwner) { await ctx.reply('❌ اطلاعات کانال ناقص است.'); return; } await this.channelBanService.ban(banChannelId, banTargetId, reason, bannedUntil); const user = await this.usersService.findById(banTargetId); await ctx.reply('✅ کاربر از این کانال بن شد.'); if (user) { await this.telegramApiService.sendMessage( user.telegramId, `⛔ دسترسی شما به این کانال محدود شد.\nدلیل: ${reason}`, ).catch(() => undefined); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-bank-card-holder.handler.ts ############################################################ . import { Injectable } from "@nestjs/common"; import { OnTelegramMessageStep } from "../../decorators/telegram-message-step.decorator"; import { TelegramHandler } from "../../dispatcher/handler.interface"; import { BankCardService } from "src/modules/bank-cards/services/bank-card.service"; import { TelegramSessionService } from "../../services/telegram-session.service"; import { TelegramContext } from "../../context/telegram.context"; import { TelegramMessages } from "../../constants/messages"; import { BankCardKeyboard } from "../../keyboards/bank-card.keyboard"; @OnTelegramMessageStep('WAITING_BANK_CARD_HOLDER') @Injectable() export class WaitingBankCardHolderHandler implements TelegramHandler { constructor( private readonly sessionService: TelegramSessionService, private readonly bankCardService: BankCardService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const holderName = ctx.messageText?.trim(); const { pendingCardNumber } = ctx.session.data; if (!holderName) { await ctx.reply('👤 اسم صاحب این کارت چیه؟ (مثلاً: محمدرضا احمدی)'); return; } if (!pendingCardNumber) { await ctx.reply(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser.id); return; } if (ctx.isAdmin) { await this.bankCardService.addForAdmin(ctx.currentUser.id, pendingCardNumber, holderName); } else { await this.bankCardService.addForOwner(ctx.currentOwner!.id, pendingCardNumber, holderName); } await this.sessionService.resetStep(ctx.currentUser.id); const [cards, total] = ctx.isAdmin ? await Promise.all([ this.bankCardService.listForAdminPaginated(ctx.currentUser.id, 0, 10), this.bankCardService.countForAdmin(ctx.currentUser.id), ]) : await Promise.all([ this.bankCardService.listForOwnerPaginated(ctx.currentOwner!.id, 0, 10), this.bankCardService.countForOwner(ctx.currentOwner!.id), ]); await ctx.reply( '✅ کارتت با موفقیت ثبت شد! از این به بعد مشتریات می‌تونن بهش پول بریزن.', { reply_markup: BankCardKeyboard.list(cards, 0, total > 10) }, ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-bank-card-number.handler.ts ############################################################ . import { Injectable } from "@nestjs/common"; import { OnTelegramMessageStep } from "../../decorators/telegram-message-step.decorator"; import { TelegramHandler } from "../../dispatcher/handler.interface"; import { TelegramSessionService } from "../../services/telegram-session.service"; import { TelegramContext } from "../../context/telegram.context"; // messages/waiting-bank-card-number.handler.ts @OnTelegramMessageStep('WAITING_BANK_CARD_NUMBER') @Injectable() export class WaitingBankCardNumberHandler implements TelegramHandler { constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const cardNumber = ctx.messageText?.replace(/\s/g, ''); if (!cardNumber || !/^\d{16}$/.test(cardNumber)) { await ctx.reply('❌ این شماره درست به نظر نمی‌رسه، باید دقیقاً ۱۶ رقم باشه. دوباره امتحان کن:',); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BANK_CARD_HOLDER', data: { ...ctx.session.data, pendingCardNumber: cardNumber }, }); await ctx.reply('👤 اسم صاحب این کارت چیه؟ (مثلاً: محمدرضا احمدی)',); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-bot-plan-duration.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; @OnTelegramMessageStep('WAITING_BOT_PLAN_DURATION_VALUE') @Injectable() export class WaitingBotPlanDurationHandler implements TelegramHandler { private readonly logger = new Logger(WaitingBotPlanDurationHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.isAdmin) return; const durationValue = Number(ctx.messageText); if (!ctx.messageText || !durationValue || durationValue <= 0) { await ctx.reply(TelegramMessages.INVALID_DURATION); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BOT_PLAN_PRICE', data: { ...ctx.session.data, durationValue }, }); await ctx.reply(TelegramMessages.ENTER_BOT_PLAN_PRICE); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-bot-plan-price.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { BotPlanService } from 'src/modules/bot-plans/services/bot-plan.service'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; @OnTelegramMessageStep('WAITING_BOT_PLAN_PRICE') @Injectable() export class WaitingBotPlanPriceHandler implements TelegramHandler { private readonly logger = new Logger(WaitingBotPlanPriceHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly botPlanService: BotPlanService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.isAdmin) return; const text = ctx.messageText; if (!text) { await ctx.reply(TelegramMessages.ENTER_BOT_PLAN_PRICE); return; } const price = Number(text.replace(/,/g, '')); if (Number.isNaN(price) || price <= 0) { await ctx.reply(TelegramMessages.INVALID_PRICE); return; } const { title, durationUnit, durationValue } = ctx.session.data; if (!title || !durationUnit || !durationValue) { await ctx.reply(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser.id); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BOT_PLAN_CONFIRM', data: { ...ctx.session.data, price, }, }); const unitFa = durationUnit === 'DAY' ? 'روز' : durationUnit === 'MONTH' ? 'ماه' : 'سال'; await ctx.reply( [ '📋 خلاصه پلن اشتراک ربات:', `عنوان: ${title}`, `مدت: ${durationValue} ${unitFa}`, `قیمت: ${price.toLocaleString()} تومان`, '', 'آیا تایید می‌کنید؟', ].join('\n'), { reply_markup: AdminKeyboard.confirmCreate(), }, ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-bot-plan-title.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; @OnTelegramMessageStep('WAITING_BOT_PLAN_TITLE') @Injectable() export class WaitingBotPlanTitleHandler implements TelegramHandler { private readonly logger = new Logger(WaitingBotPlanTitleHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.isAdmin) return; const title = ctx.messageText?.trim(); if (!title) { await ctx.reply(TelegramMessages.ENTER_BOT_PLAN_TITLE); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_BOT_PLAN_DURATION_UNIT', data: { ...ctx.session.data, title }, }); await ctx.reply('⏳ واحد زمان را انتخاب کنید:', { reply_markup: AdminKeyboard.durationUnits() }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-broadcast-content.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { ChannelService } from 'src/modules/channels/services/channel.service'; import { BroadcastKeyboard } from '../../keyboards/broadcast.keyboard'; @OnTelegramMessageStep('WAITING_BROADCAST_CONTENT') @Injectable() export class WaitingBroadcastContentHandler implements TelegramHandler { private readonly logger = new Logger(WaitingBroadcastContentHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly channelService: ChannelService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const content = ctx.messageText?.trim(); if (!content) { await ctx.reply(TelegramMessages.ENTER_BROADCAST_CONTENT); return; } const { broadcastChannelId } = ctx.session.data as any; await this.sessionService.update(ctx.currentUser.id, { step: 'IDLE', data: { ...ctx.session.data, broadcastPendingContent: content }, }); const targetLabel = broadcastChannelId ? (await this.channelService.findById(broadcastChannelId))?.title ?? 'کانال نامشخص' : 'همه‌ی مشترکین من'; await ctx.reply(TelegramMessages.BROADCAST_CONFIRM(targetLabel, content), { reply_markup: BroadcastKeyboard.confirm(), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-member-search-query.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { SubscriptionService } from 'src/modules/subscriptions/services/subscription.service'; import { MemberKeyboard } from '../../keyboards/member.keyboard'; import { formatPersianDate } from 'src/common/utils/date.util'; @OnTelegramMessageStep('WAITING_MEMBER_SEARCH_QUERY') @Injectable() export class WaitingMemberSearchQueryHandler implements TelegramHandler { private readonly logger = new Logger(WaitingMemberSearchQueryHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly subscriptionService: SubscriptionService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.currentOwner || !ctx.session) return; const query = ctx.messageText?.trim(); const { memberSearchChannelId } = ctx.session.data; if (!query || !memberSearchChannelId) return; const results = await this.subscriptionService.searchByChannelAndQuery(memberSearchChannelId, query); await this.sessionService.resetStep(ctx.currentUser.id); if (!results.length) { await ctx.reply(TelegramMessages.NO_SEARCH_RESULTS, { reply_markup: MemberKeyboard.submenu(memberSearchChannelId), }); return; } const mapped = results.map((m) => ({ subscriptionId: m.id, label: `${m.status === 'ACTIVE' ? '🟢' : '🔴'} ${m.user.firstName ?? m.user.username ?? m.user.telegramId}` + (m.status === 'ACTIVE' ? ` — تا ${formatPersianDate(m.expiresAt)}` : ''), })); await ctx.reply('🔍 نتایج جستجو:', { reply_markup: MemberKeyboard.memberListWithRemove(mapped, memberSearchChannelId), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-new-discount-code.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { DiscountService } from 'src/modules/discounts/services/discount.service'; import { DiscountKeyboard } from '../../keyboards/discount.keyboard'; @OnTelegramMessageStep('WAITING_NEW_DISCOUNT_CODE') @Injectable() export class WaitingNewDiscountCodeHandler implements TelegramHandler { private readonly logger = new Logger(WaitingNewDiscountCodeHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly discountService: DiscountService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const code = ctx.messageText?.trim().toUpperCase(); if (!code) { await ctx.reply(TelegramMessages.ENTER_NEW_DISCOUNT_CODE); return; } const existing = await this.discountService.findByCode(code); // 🆕 متد ساده، پایین توضیح دادم if (existing) { await ctx.reply(TelegramMessages.DISCOUNT_CODE_DUPLICATE); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'IDLE', data: { ...ctx.session.data, newDiscountCode: code }, }); await ctx.reply(TelegramMessages.SELECT_DISCOUNT_TYPE, { reply_markup: DiscountKeyboard.selectType() }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-new-discount-expiry.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { DiscountKeyboard } from '../../keyboards/discount.keyboard'; @OnTelegramMessageStep('WAITING_NEW_DISCOUNT_EXPIRY_DAYS') @Injectable() export class WaitingNewDiscountExpiryHandler implements TelegramHandler { private readonly logger = new Logger(WaitingNewDiscountExpiryHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const days = Number(ctx.messageText); if (!days || days <= 0) { await ctx.reply(TelegramMessages.INVALID_DISCOUNT_VALUE); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'IDLE', data: { ...ctx.session.data, newDiscountExpiresInDays: days }, }); const { newDiscountCode, newDiscountType, newDiscountValue, newDiscountMaxUsage } = ctx.session.data; const typeFa = newDiscountType === 'PERCENTAGE' ? 'درصدی' : 'مبلغ ثابت'; const valueFa = newDiscountType === 'PERCENTAGE' ? `${newDiscountValue}٪` : `${newDiscountValue?.toLocaleString()} تومان`; await ctx.reply( [ '📋 خلاصه کد تخفیف:', `کد: ${newDiscountCode}`, `نوع: ${typeFa}`, `مقدار: ${valueFa}`, `سقف استفاده: ${newDiscountMaxUsage ? newDiscountMaxUsage : 'نامحدود'}`, `انقضا: ${days} روز دیگر`, '', 'آیا تایید می‌کنید؟', ].join('\n'), { reply_markup: DiscountKeyboard.confirmCreate() }, ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-new-discount-max-usage.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { DiscountKeyboard } from '../../keyboards/discount.keyboard'; @OnTelegramMessageStep('WAITING_NEW_DISCOUNT_MAX_USAGE') @Injectable() export class WaitingNewDiscountMaxUsageHandler implements TelegramHandler { private readonly logger = new Logger(WaitingNewDiscountMaxUsageHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const maxUsage = Number(ctx.messageText); if (!maxUsage || maxUsage <= 0) { await ctx.reply(TelegramMessages.INVALID_DISCOUNT_VALUE); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_NEW_DISCOUNT_EXPIRY_DAYS', data: { ...ctx.session.data, newDiscountMaxUsage: maxUsage }, }); await ctx.reply(TelegramMessages.ASK_DISCOUNT_EXPIRY, { reply_markup: DiscountKeyboard.skipExpiry(), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-new-discount-value.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { DiscountKeyboard } from '../../keyboards/discount.keyboard'; @OnTelegramMessageStep('WAITING_NEW_DISCOUNT_VALUE') @Injectable() export class WaitingNewDiscountValueHandler implements TelegramHandler { private readonly logger = new Logger(WaitingNewDiscountValueHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const value = Number(ctx.messageText?.replace(/,/g, '')); const type = ctx.session.data.newDiscountType; if (!value || value <= 0 || (type === 'PERCENTAGE' && value > 100)) { await ctx.reply(TelegramMessages.INVALID_DISCOUNT_VALUE); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_NEW_DISCOUNT_MAX_USAGE', data: { ...ctx.session.data, newDiscountValue: value }, }); await ctx.reply(TelegramMessages.ASK_DISCOUNT_MAX_USAGE, { reply_markup: DiscountKeyboard.skipMaxUsage(), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-outage-end-date.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { ServiceOutageService } from 'src/modules/service-outage/services/service-outage.service'; import { parseJalaliDateTime } from 'src/common/utils/jalali-date.util'; import { TelegramMessages } from '../../constants/messages'; import { AdminOutageHandler } from '../callbacks/admin-outage.handler'; @OnTelegramMessageStep('WAITING_OUTAGE_END_DATE') @Injectable() export class WaitingOutageEndDateHandler implements TelegramHandler { private readonly logger = new Logger(WaitingOutageEndDateHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly outageService: ServiceOutageService, private readonly adminOutageHandler: AdminOutageHandler, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.isAdmin) return; // 🔒 گارد مستقل const text = ctx.messageText?.trim(); const endDate = text ? parseJalaliDateTime(text) : null; const startIso = ctx.session.data.outageStartDate; if (!startIso) { await ctx.reply(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser.id); return; } const startDate = new Date(startIso); if (!endDate) { await ctx.reply('❌ فرمت تاریخ درست نیست. دوباره وارد کنید:'); return; } if (endDate <= startDate) { await ctx.reply('❌ تاریخ پایان باید بعد از تاریخ شروع باشد. دوباره وارد کنید:'); return; } const outage = await this.outageService.declareManualOutage(startDate, endDate); await this.sessionService.resetStep(ctx.currentUser.id); await this.adminOutageHandler.showReportFor(ctx, outage.id); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-outage-start-date.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { parseJalaliDateTime } from 'src/common/utils/jalali-date.util'; @OnTelegramMessageStep('WAITING_OUTAGE_START_DATE') @Injectable() export class WaitingOutageStartDateHandler implements TelegramHandler { private readonly logger = new Logger(WaitingOutageStartDateHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.isAdmin) return; // 🔒 گارد مستقل const text = ctx.messageText?.trim(); const date = text ? parseJalaliDateTime(text) : null; if (!date) { await ctx.reply('❌ فرمت تاریخ درست نیست. مثال: 1403/05/12 یا 1403/05/12 14:30\nدوباره وارد کنید:'); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_OUTAGE_END_DATE', data: { ...ctx.session.data, outageStartDate: date.toISOString() }, }); await ctx.reply('📅 حالا تاریخ و ساعت پایان قطعی را وارد کنید (مثال: 1403/05/12 18:45):'); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-payment-receipt.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramPhotoStep } from '../../decorators/telegram-photo-step.decorator'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { PaymentRequestService } from 'src/modules/payment-requests/services/payment-request.service'; import { TelegramApiService } from '../../services/telegram-api.service'; import { PaymentApprovalKeyboard } from '../../keyboards/payment-approval.keyboard'; @OnTelegramPhotoStep('WAITING_PAYMENT_RECEIPT') @Injectable() export class WaitingPaymentReceiptHandler implements TelegramHandler { private readonly logger = new Logger(WaitingPaymentReceiptHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly paymentRequestService: PaymentRequestService, private readonly telegramApiService: TelegramApiService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const { pendingPaymentRequestId } = ctx.session.data; if (!pendingPaymentRequestId) { await ctx.reply('⌛ اطلاعات این خرید منقضی شده است. لطفاً دوباره شروع کنید.'); await this.sessionService.resetStep(ctx.currentUser.id); return; } const photos = ctx.message?.photo; if (!photos?.length) return; const fileId = photos[photos.length - 1].file_id; // بزرگ‌ترین سایز try { const request = await this.paymentRequestService.submitReceipt( pendingPaymentRequestId, fileId, ctx.currentUser.id, ); await this.sessionService.resetStep(ctx.currentUser.id); const successText = request.type === 'BOT_SUBSCRIPTION' ? '✅ فیشت رو گرفتم و برای سازنده‌ی ربات فرستادم بررسی کنه.\nبه‌محض تاییدش، اشتراک ربات برات فعال می‌شه — بهت خبر می‌دم!' : '✅ فیشت رو گرفتم و برای صاحب کانال فرستادم بررسی کنه.\nبه‌محض تاییدش، لینک عضویت کانال رو برات می‌فرستم.'; await ctx.reply(successText); await this.notifyReceiver(ctx, request); } catch (error) { this.logger.error('Failed to submit receipt', error); await ctx.reply('❌ خطایی رخ داد. لطفاً دوباره تلاش کنید.'); } } private async notifyReceiver(ctx: TelegramContext, request: any): Promise { const full = await this.paymentRequestService.findById(request.id); if (!full) return; const receiverTelegramId = full.type === 'CHANNEL_SUBSCRIPTION' ? (await this.paymentRequestService.getReceiverOwnerTelegramId(request.id)) : (await this.paymentRequestService.getReceiverAdminTelegramId(request.id)); if (!receiverTelegramId) return; // بعد const buyerName = [full.payer.firstName, full.payer.lastName].filter(Boolean).join(' ') || '—'; const caption = [ full.type === 'BOT_SUBSCRIPTION' ? '🔔 یه درخواست خرید/تمدید اشتراک ربات جدید داری!' : '🔔 یه درخواست خرید/تمدید اشتراک کانال جدید داری!', `کد پیگیری: ${full.paymentCode}`, `مبلغ: ${full.amount.toLocaleString()} تومان`, '', `👤 خریدار: ${buyerName}`, `🔗 یوزرنیم: ${full.payer.username ? '@' + full.payer.username : '—'}`, `🆔 آیدی عددی: ${full.payer.telegramId}`, '', 'عکس فیش رو نگاه کن و اگه درست بود، تاییدش کن 👇', ].join('\n') await this.telegramApiService.sendPhotoWithKeyboard( receiverTelegramId, full.receiptFileId!, caption, PaymentApprovalKeyboard.approveReject(request.id), ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-plan-duration.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; @OnTelegramMessageStep('WAITING_PLAN_DURATION_VALUE') @Injectable() export class WaitingPlanDurationHandler implements TelegramHandler { private readonly logger = new Logger(WaitingPlanDurationHandler.name); constructor(private readonly sessionService: TelegramSessionService) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const durationValue = Number(ctx.messageText); if (!ctx.messageText || !durationValue || durationValue <= 0) { await ctx.reply(TelegramMessages.INVALID_DURATION); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_PLAN_PRICE', data: { ...ctx.session.data, durationValue }, }); await ctx.reply(TelegramMessages.ENTER_PLAN_PRICE); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-plan-edit-value.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { PlanService } from 'src/modules/plans/services/plan.service'; import { PlanKeyboard } from '../../keyboards/plan.keyboard'; @OnTelegramMessageStep('WAITING_PLAN_EDIT_VALUE') @Injectable() export class WaitingPlanEditValueHandler implements TelegramHandler { private readonly logger = new Logger(WaitingPlanEditValueHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly planService: PlanService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session || !ctx.currentOwner) return; const { planId, editField } = ctx.session.data; if (!planId || !editField) { await ctx.reply(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser.id); return; } const plan = await this.planService.findByIdForOwner(planId, ctx.currentOwner.id); // 🔧 if (!plan) { await ctx.reply(TelegramMessages.PLAN_NOT_FOUND); await this.sessionService.resetStep(ctx.currentUser.id); return; } const rawText = ctx.messageText?.trim(); if (!rawText) return; try { if (editField === 'title') { const duplicate = await this.planService.findByChannelAndTitle(plan.channelId, rawText); if (duplicate && duplicate.id !== plan.id) { await ctx.reply(TelegramMessages.PLAN_TITLE_DUPLICATE); return; } await this.planService.update(planId, { title: rawText }); } else if (editField === 'price') { const price = Number(rawText.replace(/,/g, '')); if (Number.isNaN(price) || price <= 0) { await ctx.reply(TelegramMessages.INVALID_PRICE); return; } await this.planService.update(planId, { price }); } else if (editField === 'durationValue') { const durationValue = Number(rawText); if (!durationValue || durationValue <= 0) { await ctx.reply(TelegramMessages.INVALID_DURATION); return; } await this.planService.update(planId, { durationValue }); } await this.sessionService.resetStep(ctx.currentUser.id); await ctx.reply(TelegramMessages.PLAN_UPDATED); await ctx.reply('💳 مدیریت پلن‌ها', { reply_markup: PlanKeyboard.backToList() }); } catch (error) { this.logger.error('Failed to update plan', error); await ctx.reply(TelegramMessages.SOMETHING_WENT_WRONG); } } // async execute(ctx: TelegramContext): Promise { // if (!ctx.currentUser || !ctx.session) return; // const { planId, editField } = ctx.session.data; // if (!planId || !editField) { // await ctx.reply(TelegramMessages.PLAN_FLOW_EXPIRED); // await this.sessionService.resetStep(ctx.currentUser.id); // return; // } // const plan = await this.planService.findById(planId); // if (!plan) { // await ctx.reply(TelegramMessages.PLAN_NOT_FOUND); // await this.sessionService.resetStep(ctx.currentUser.id); // return; // } // const rawText = ctx.messageText?.trim(); // if (!rawText) return; // try { // if (editField === 'title') { // const duplicate = await this.planService.findByChannelAndTitle(plan.channelId, rawText); // if (duplicate && duplicate.id !== plan.id) { // await ctx.reply(TelegramMessages.PLAN_TITLE_DUPLICATE); // return; // } // await this.planService.update(planId, { title: rawText }); // } else if (editField === 'price') { // const price = Number(rawText.replace(/,/g, '')); // if (Number.isNaN(price) || price <= 0) { // await ctx.reply(TelegramMessages.INVALID_PRICE); // return; // } // await this.planService.update(planId, { price }); // } else if (editField === 'durationValue') { // const durationValue = Number(rawText); // if (!durationValue || durationValue <= 0) { // await ctx.reply(TelegramMessages.INVALID_DURATION); // return; // } // await this.planService.update(planId, { durationValue }); // } // await this.sessionService.resetStep(ctx.currentUser.id); // await ctx.reply(TelegramMessages.PLAN_UPDATED); // await ctx.reply('💳 مدیریت پلن‌ها', { reply_markup: PlanKeyboard.menu() }); // } catch (error) { // this.logger.error('Failed to update plan', error); // await ctx.reply(TelegramMessages.SOMETHING_WENT_WRONG); // } // } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-plan-price.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { PlanService } from 'src/modules/plans/services/plan.service'; import { PlanKeyboard } from '../../keyboards/plan.keyboard'; @OnTelegramMessageStep('WAITING_PLAN_PRICE') @Injectable() export class WaitingPlanPriceHandler implements TelegramHandler { private readonly logger = new Logger(WaitingPlanPriceHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly planService: PlanService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const text = ctx.messageText; if (!text) { await ctx.reply(TelegramMessages.ENTER_PLAN_PRICE); return; } const price = Number(text.replace(/,/g, '')); if (Number.isNaN(price) || price <= 0) { await ctx.reply(TelegramMessages.INVALID_PRICE); return; } const { channelId, title, durationUnit, durationValue } = ctx.session.data; if (!channelId || !title || !durationUnit || !durationValue) { await ctx.reply(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser.id); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_PLAN_CONFIRM', // 🆕 فقط یک marker؛ هیچ Handler پیامی روش رجیستر نیست data: { ...ctx.session.data, price }, }); const unitFa = durationUnit === 'DAY' ? 'روز' : durationUnit === 'MONTH' ? 'ماه' : 'سال'; await ctx.reply( [ '📋 خلاصه پلن جدید:', `عنوان: ${title}`, `مدت: ${durationValue} ${unitFa}`, `قیمت: ${price.toLocaleString()} تومان`, '', 'آیا تایید می‌کنید؟', ].join('\n'), { reply_markup: PlanKeyboard.confirmCreate() }, ); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-plan-title.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { PlanService } from 'src/modules/plans/services/plan.service'; import { PlanKeyboard } from '../../keyboards/plan.keyboard'; @OnTelegramMessageStep('WAITING_PLAN_TITLE') @Injectable() export class WaitingPlanTitleHandler implements TelegramHandler { private readonly logger = new Logger(WaitingPlanTitleHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly planService: PlanService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const title = ctx.messageText?.trim(); if (!title) { await ctx.reply(TelegramMessages.ENTER_PLAN_TITLE); return; } const { channelId } = ctx.session.data; if (!channelId) { await ctx.reply(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser.id); return; } const duplicate = await this.planService.findByChannelAndTitle(channelId, title); if (duplicate) { await ctx.reply(TelegramMessages.PLAN_TITLE_DUPLICATE); return; } await this.sessionService.update(ctx.currentUser.id, { step: 'WAITING_PLAN_DURATION_UNIT', data: { ...ctx.session.data, title }, }); await ctx.reply('⏳ واحد زمان اشتراک را انتخاب کنید:', { reply_markup: PlanKeyboard.durationUnits(), }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-redeem-discount-code.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramMessages } from '../../constants/messages'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { DiscountService } from 'src/modules/discounts/services/discount.service'; import { PlanService } from 'src/modules/plans/services/plan.service'; import { BotPlanService } from 'src/modules/bot-plans/services/bot-plan.service'; import { SubscriptionMenuHandler } from '../callbacks/subscription-menu.handler'; import { BotSubscriptionHandler } from '../callbacks/bot-subscription.handler'; import { Callback } from '../../constants/callback'; import { InlineKeyboard } from 'grammy'; @OnTelegramMessageStep('WAITING_REDEEM_DISCOUNT_CODE') @Injectable() export class WaitingRedeemDiscountCodeHandler implements TelegramHandler { private readonly logger = new Logger(WaitingRedeemDiscountCodeHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly discountService: DiscountService, private readonly planService: PlanService, private readonly botPlanService: BotPlanService, private readonly subscriptionMenuHandler: SubscriptionMenuHandler, private readonly botSubscriptionHandler: BotSubscriptionHandler, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const code = ctx.messageText?.trim().toUpperCase(); const pending = ctx.session.data.pendingPurchase; if (!code) { await ctx.reply(TelegramMessages.ENTER_DISCOUNT_CODE); return; } if (!pending) { await ctx.reply(TelegramMessages.PLAN_FLOW_EXPIRED); await this.sessionService.resetStep(ctx.currentUser.id); return; } if (pending.kind === 'CHANNEL_PLAN') { const discount = await this.discountService.validateForPlan(code, pending.planId!); if (!discount) { await ctx.reply(TelegramMessages.INVALID_DISCOUNT_CODE, { reply_markup: new InlineKeyboard() .text('رد شدن، بدون کد', Callback.SUBSCRIPTION.SKIP_DISCOUNT), }); return; } const plan = await this.planService.findById(pending.planId!); const finalAmount = this.discountService.calculateFinalAmount(plan!.price, discount); await this.subscriptionMenuHandler.startPaymentRequestFlow(ctx, pending.planId!, { subscriptionId: pending.subscriptionId, renewOrUpgrade: pending.renewOrUpgrade, discountCodeId: discount.id, finalAmount, }); return; } // BOT_PLAN const discount = await this.discountService.validateForBotPlan(code, pending.botPlanId!); if (!discount) { await ctx.reply(TelegramMessages.INVALID_DISCOUNT_CODE, { reply_markup: new InlineKeyboard() .text('رد شدن، بدون کد', Callback.SUBSCRIPTION.SKIP_DISCOUNT), }); return; } // if (!discount) { // await ctx.reply(TelegramMessages.INVALID_DISCOUNT_CODE); // return; // } const plan = await this.botPlanService.findById(pending.botPlanId!); const finalAmount = this.discountService.calculateFinalAmount(Number(plan!.price), discount); await this.botSubscriptionHandler.startBotPayment(ctx, pending.botPlanId!, { discountCodeId: discount.id, finalAmount, }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\handlers\messages\waiting-reject-reason.handler.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { TelegramContext } from '../../context/telegram.context'; import { TelegramHandler } from '../../dispatcher/handler.interface'; import { OnTelegramMessageStep } from '../../decorators/telegram-message-step.decorator'; import { TelegramSessionService } from '../../services/telegram-session.service'; import { PaymentRequestService } from 'src/modules/payment-requests/services/payment-request.service'; import { TelegramApiService } from '../../services/telegram-api.service'; import { TelegramMessages } from '../../constants/messages'; import { AdminKeyboard } from '../../keyboards/admin.keyboard'; import { OwnerKeyboard } from '../../keyboards/owner.keyboard'; @OnTelegramMessageStep('WAITING_REJECT_REASON') @Injectable() export class WaitingRejectReasonHandler implements TelegramHandler { private readonly logger = new Logger(WaitingRejectReasonHandler.name); constructor( private readonly sessionService: TelegramSessionService, private readonly paymentRequestService: PaymentRequestService, private readonly telegramApiService: TelegramApiService, ) { } async execute(ctx: TelegramContext): Promise { if (!ctx.currentUser || !ctx.session) return; const reason = ctx.messageText?.trim(); const { pendingPaymentRequestId } = ctx.session.data; if (!reason || !pendingPaymentRequestId) { await ctx.reply('لطفاً دلیل رد را بنویسید.'); return; } try { // 🆕 اول باید بفهمیم این درخواست برای اشتراک کانال بوده یا ربات، // چون مسیر رد کردن و صاحب واقعی درخواست برای این دو فرق می‌کنه. const requestInfo = await this.paymentRequestService.findById(pendingPaymentRequestId); if (!requestInfo) { await ctx.reply(TelegramMessages.SOMETHING_WENT_WRONG); await this.sessionService.resetStep(ctx.currentUser.id); return; } let request; if (requestInfo.type === 'BOT_SUBSCRIPTION') { if (!ctx.isAdmin) { await ctx.reply(TelegramMessages.ACCESS_DENIED); await this.sessionService.resetStep(ctx.currentUser.id); return; } request = await this.paymentRequestService.rejectBotSubscription( pendingPaymentRequestId, ctx.currentUser.id, reason, ); } else { if (!ctx.currentOwner) { await ctx.reply(TelegramMessages.ACCESS_DENIED); await this.sessionService.resetStep(ctx.currentUser.id); return; } request = await this.paymentRequestService.reject(pendingPaymentRequestId, ctx.currentOwner.id, reason); } await this.sessionService.resetStep(ctx.currentUser.id); await ctx.reply('❌ پرداخت رد شد و به کاربر اطلاع داده شد.'); console.log(ctx.role); if (ctx.role === 'ADMIN') { await ctx.reply('👑 پنل سازنده ربات', { reply_markup: AdminKeyboard.menu() }); } else { await ctx.reply('پنل مدیریت', { reply_markup: OwnerKeyboard.menu() }); } if (request) { await this.telegramApiService.sendMessage( request.payer.telegramId, [ '❌ متاسفانه پرداختت رد شد.', `دلیل: ${reason}`, '', 'اگه فکر می‌کنی اشتباهیه یا می‌خوای دوباره تلاش کنی، می‌تونی یه فیش جدید برام بفرستی.', ].join('\n'), ); } } catch (error) { this.logger.error('Failed to reject payment request', error); await ctx.reply(TelegramMessages.SOMETHING_WENT_WRONG); } } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\admin.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class AdminKeyboard { static menu(): InlineKeyboard { return new InlineKeyboard() .text('📺 نمای کلی کانال‌ها', Callback.ADMIN.CHANNELS_OVERVIEW) .row() .text('💳 پلن‌های اشتراک ربات', Callback.ADMIN.BOT_PLAN_MENU) .row() .text('💳 کارت‌های بانکی', Callback.BANK_CARD.MENU) .row() .text('⚠️ ثبت قطعی دستی', Callback.OUTAGE.MANUAL_START) .text('📋 وضعیت قطعی', Callback.OUTAGE.REPORT) .row() .text('📥 درخواست‌های در انتظار تایید', Callback.PAYMENT_REQUEST.RECEIVER_LIST) .row() .text('🚫 مدیریت صاحبان کانال (بن)', `${Callback.BAN.OWNER_LIST}:0`) .row() .text('⚠️ مدیریت قطعی اینترنت', Callback.OUTAGE.REPORT) .row() .text('👥 لیست/جستجوی کاربران', `${Callback.ADMIN.USER_LIST}:0`) .row() .text('🎟 کدهای تخفیف سراسری', Callback.ADMIN.DISCOUNT_MENU); } static backToPanel(): InlineKeyboard { return new InlineKeyboard().text(KeyboardConstants.BACK, Callback.ADMIN.PANEL); } static botPlanMenu(): InlineKeyboard { return new InlineKeyboard() .text('➕ ایجاد پلن', Callback.ADMIN.BOT_PLAN_CREATE) .row() .text('📋 لیست پلن‌ها', Callback.ADMIN.BOT_PLAN_LIST) .row() .text(KeyboardConstants.BACK, Callback.ADMIN.PANEL); } static durationUnits(): InlineKeyboard { return new InlineKeyboard() .text('روز', `${Callback.ADMIN.BOT_PLAN_DURATION_UNIT}:DAY`).row() .text('ماه', `${Callback.ADMIN.BOT_PLAN_DURATION_UNIT}:MONTH`).row() .text('سال', `${Callback.ADMIN.BOT_PLAN_DURATION_UNIT}:YEAR`).row() .text(KeyboardConstants.BACK, Callback.ADMIN.BOT_PLAN_MENU); } static confirmCreate(): InlineKeyboard { return new InlineKeyboard() .text('✅ تایید و ثبت', Callback.ADMIN.BOT_PLAN_CONFIRM_CREATE) .row() .text('✏️ عنوان', Callback.ADMIN.BOT_PLAN_EDIT_CREATE_TITLE) .text('✏️ مدت', Callback.ADMIN.BOT_PLAN_EDIT_CREATE_DURATION) .text('✏️ قیمت', Callback.ADMIN.BOT_PLAN_EDIT_CREATE_PRICE) .row() .text('❌ انصراف', Callback.ADMIN.BOT_PLAN_CANCEL_CREATE); } static planList(plans: { id: string; title: string; price: number }[]): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const p of plans) { keyboard .text(`${p.title} — ${p.price.toLocaleString()}`, 'noop') // فقط نمایشی، کلیک‌پذیر نیست عملاً // .text('✏️ ویرایش', `${Callback.PLAN.EDIT}:${plan.id}`) .text('🗑', `${Callback.ADMIN.BOT_PLAN_DELETE}:${p.id}`) .row(); } keyboard.text(KeyboardConstants.BACK, Callback.ADMIN.BOT_PLAN_MENU); return keyboard; } static discountMenu(): InlineKeyboard { return new InlineKeyboard() .text('➕ ایجاد کد جدید', Callback.ADMIN.DISCOUNT_CREATE) .row() .text('📋 لیست کدها', Callback.ADMIN.DISCOUNT_LIST) .row() .text(KeyboardConstants.BACK, Callback.ADMIN.PANEL); } static discountSelectScope(): InlineKeyboard { return new InlineKeyboard() .text('💳 خرید اشتراک کانال', `${Callback.ADMIN.DISCOUNT_SELECT_SCOPE}:CHANNEL_PLAN`) .row() .text('🤖 خرید اشتراک ربات', `${Callback.ADMIN.DISCOUNT_SELECT_SCOPE}:BOT_PLAN`) .row() .text(KeyboardConstants.BACK, Callback.ADMIN.DISCOUNT_MENU); } static discountSelectType(): InlineKeyboard { return new InlineKeyboard() .text('٪ درصدی', `${Callback.ADMIN.DISCOUNT_SELECT_TYPE}:PERCENTAGE`) .text('💰 مبلغ ثابت', `${Callback.ADMIN.DISCOUNT_SELECT_TYPE}:FIXED`) .row() .text(KeyboardConstants.BACK, Callback.ADMIN.DISCOUNT_MENU); } static discountSkipMaxUsage(): InlineKeyboard { return new InlineKeyboard().text('رد شدن، بدون محدودیت', Callback.ADMIN.DISCOUNT_SKIP_MAX_USAGE); } static discountSkipExpiry(): InlineKeyboard { return new InlineKeyboard().text('رد شدن، بدون انقضا', Callback.ADMIN.DISCOUNT_SKIP_EXPIRY); } static discountConfirm(): InlineKeyboard { return new InlineKeyboard() .text('✅ تایید و ثبت', Callback.ADMIN.DISCOUNT_CONFIRM_CREATE) .row() .text('❌ انصراف', Callback.ADMIN.DISCOUNT_CANCEL_CREATE); } static discountList( codes: { id: string; code: string }[], page: number, hasNextPage: boolean, ): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const c of codes) { keyboard .copyText(`🎟 ${c.code}`, c.code) .text('🗑', `${Callback.ADMIN.DISCOUNT_DELETE}:${c.id}`) .row(); } if (page > 0) keyboard.text('⬅️ قبلی', `${Callback.ADMIN.DISCOUNT_MENU}:${page - 1}`); if (hasNextPage) keyboard.text('➡️ بعدی', `${Callback.ADMIN.DISCOUNT_MENU}:${page + 1}`); if (page > 0 || hasNextPage) keyboard.row(); keyboard.text('➕ ایجاد کد جدید', Callback.ADMIN.DISCOUNT_CREATE).row(); keyboard.text(KeyboardConstants.BACK, Callback.ADMIN.PANEL); return keyboard; } static channelsOverviewNav(page: number, hasNextPage: boolean): InlineKeyboard { const keyboard = new InlineKeyboard(); if (page > 0) { keyboard.text('⬅️ قبلی', `${Callback.ADMIN.CHANNELS_OVERVIEW}:${page - 1}`); } if (hasNextPage) { keyboard.text('➡️ بعدی', `${Callback.ADMIN.CHANNELS_OVERVIEW}:${page + 1}`); } if (page > 0 || hasNextPage) { keyboard.row(); } keyboard.text(KeyboardConstants.BACK, Callback.ADMIN.PANEL); return keyboard; } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\ban.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class BanKeyboard { static ownersListNav( owners: { id: string; displayName: string; banStatus: string }[], page: number, hasNextPage: boolean, ): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const o of owners) { const icon = o.banStatus === 'NONE' ? '✅' : '⛔'; keyboard.text(`${icon} ${o.displayName}`, `${Callback.BAN.OWNER_MENU}:${o.id}`).row(); } if (page > 0) keyboard.text('⬅️ قبلی', `${Callback.BAN.OWNER_LIST}:${page - 1}`); if (hasNextPage) keyboard.text('➡️ بعدی', `${Callback.BAN.OWNER_LIST}:${page + 1}`); if (page > 0 || hasNextPage) keyboard.row(); keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); return keyboard; } static ownerActions(ownerId: string, isBanned: boolean): InlineKeyboard { const keyboard = new InlineKeyboard(); if (isBanned) { keyboard.text('🔓 رفع بن', `${Callback.BAN.OWNER_UNBAN}:${ownerId}`).row(); } else { keyboard .text('⏳ بن موقت', `${Callback.BAN.OWNER_TEMP}:${ownerId}`) .text('⛔ بن دائم', `${Callback.BAN.OWNER_PERMANENT}:${ownerId}`) .row(); } keyboard.text(KeyboardConstants.BACK, `${Callback.BAN.OWNER_LIST}:0`); return keyboard; } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\bank-card.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class BankCardKeyboard { // static menu(): InlineKeyboard { // return new InlineKeyboard() // .text('➕ افزودن کارت', Callback.BANK_CARD.ADD) // .row() // .text('📋 لیست کارت‌ها', Callback.BANK_CARD.LIST) // .row() // .text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); // } static list( cards: { id: string; cardNumber: string; isDefault: boolean }[], page: number, hasNextPage: boolean, ): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const c of cards) { const label = c.isDefault ? `⭐ ${c.cardNumber}` : c.cardNumber; // 🔑 کلیک روی شماره کارت = کپی (بخش ۴ سند) keyboard.copyText(label, c.cardNumber).row(); if (!c.isDefault) { keyboard.text('✅ پیش‌فرض کردن', `${Callback.BANK_CARD.SET_DEFAULT}:${c.id}`); } keyboard.text('🗑', `${Callback.BANK_CARD.DELETE}:${c.id}`); keyboard.row(); } if (page > 0) keyboard.text('⬅️ قبلی', `${Callback.BANK_CARD.MENU}:${page - 1}`); if (hasNextPage) keyboard.text('➡️ بعدی', `${Callback.BANK_CARD.MENU}:${page + 1}`); if (page > 0 || hasNextPage) keyboard.row(); keyboard.text('➕ ایجاد شماره کارت', Callback.BANK_CARD.ADD).row(); keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); return keyboard; } static deleteConfirm(cardId: string): InlineKeyboard { return new InlineKeyboard() .text('✅ بله، حذف شود', `${Callback.BANK_CARD.DELETE_CONFIRM}:${cardId}`) .text('❌ انصراف', `${Callback.BANK_CARD.MENU}:0`); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\bot-subscription.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; export class BotSubscriptionKeyboard { static renewButton(): InlineKeyboard { return new InlineKeyboard().text('💳 خرید/تمدید اشتراک ربات', Callback.BOT_SUBSCRIPTION.MENU); } static planList(plans: { id: string; title: string; price: number }[]): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const p of plans) { keyboard.text(`${p.title} — ${p.price.toLocaleString()}`, `${Callback.BOT_SUBSCRIPTION.SELECT_PLAN}:${p.id}`).row(); } keyboard.text('🔙 بازگشت', Callback.NAVIGATION.BACK); return keyboard; } static gatewaySelect(planId: string, gateways: { code: string; displayName: string }[]): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const g of gateways) { keyboard.text(g.displayName, `${Callback.BOT_SUBSCRIPTION.SELECT_GATEWAY}:${planId}:${g.code}`).row(); } keyboard.text('🔙 بازگشت', Callback.BOT_SUBSCRIPTION.MENU); return keyboard; } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\broadcast.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class BroadcastKeyboard { static selectTarget(channels: { id: string; title: string }[]): InlineKeyboard { const keyboard = new InlineKeyboard() .text('📢 همه‌ی مشترکین من', `${Callback.BROADCAST.SELECT_TARGET}:all`) .row(); for (const c of channels) { keyboard.text(`📺 فقط ${c.title}`, `${Callback.BROADCAST.SELECT_TARGET}:${c.id}`).row(); } keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); return keyboard; } static confirm(): InlineKeyboard { return new InlineKeyboard() .text('✅ ارسال شود', Callback.BROADCAST.CONFIRM) .text('❌ انصراف', Callback.BROADCAST.CANCEL); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\channel.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class ChannelKeyboard { static list( channels: { id: string; title: string }[], page: number, hasNextPage: boolean, ): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const c of channels) { keyboard.text(`📺 ${c.title}`, `${Callback.CHANNEL.DETAIL}:${c.id}`).row(); } if (page > 0) keyboard.text('⬅️ قبلی', `${Callback.CHANNEL.MENU}:${page - 1}`); if (hasNextPage) keyboard.text('➡️ بعدی', `${Callback.CHANNEL.MENU}:${page + 1}`); if (page > 0 || hasNextPage) keyboard.row(); keyboard.text('➕ ایجاد کانال', Callback.CHANNEL.ADD).row(); keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); return keyboard; } static detail(channelId: string): InlineKeyboard { return new InlineKeyboard() .text('🔗 دریافت لینک خرید', `${Callback.CHANNEL.GET_LINK}:${channelId}`) .row() .text('💳 ایجاد پلن برای این کانال', `${Callback.PLAN.SELECT_CHANNEL}:${channelId}`) .row() .text('🗑 حذف کانال', `${Callback.CHANNEL.DELETE}:${channelId}`) .row() .text(KeyboardConstants.BACK, `${Callback.CHANNEL.MENU}:0`); } // 🆕 جایگزین منسوخ‌شده‌ی menu() — برای جاهایی که فقط نیاز به یک دکمه‌ی بازگشت به لیست دارن // (مثل بعد از ثبت موفق کانال در channel.update.ts) static backToList(): InlineKeyboard { return new InlineKeyboard().text('📺 لیست کانال‌ها', `${Callback.CHANNEL.MENU}:0`); } static recheckSettings(): InlineKeyboard { return new InlineKeyboard().text('🔄 بررسی مجدد', Callback.CHANNEL.REFRESH); } static deleteConfirm(channelId: string): InlineKeyboard { return new InlineKeyboard() .text('✅ بله، حذف شود', `${Callback.CHANNEL.DELETE_CONFIRM}:${channelId}`) .text('❌ انصراف', `${Callback.CHANNEL.DETAIL}:${channelId}`); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\discount.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class DiscountKeyboard { static list( codes: { id: string; code: string }[], page: number, hasNextPage: boolean, ): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const c of codes) { keyboard .copyText(`🎟 ${c.code}`, c.code) .text('🗑', `${Callback.DISCOUNT.DELETE}:${c.id}`) .row(); } if (page > 0) keyboard.text('⬅️ قبلی', `${Callback.DISCOUNT.MENU}:${page - 1}`); if (hasNextPage) keyboard.text('➡️ بعدی', `${Callback.DISCOUNT.MENU}:${page + 1}`); if (page > 0 || hasNextPage) keyboard.row(); keyboard.text('➕ ایجاد کد جدید', Callback.DISCOUNT.CREATE).row(); keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); return keyboard; } static selectPlan(plans: { id: string; title: string; channel: { title: string } }[]): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const p of plans) { keyboard.text(`${p.channel.title} — ${p.title}`, `${Callback.DISCOUNT.SELECT_PLAN}:${p.id}`).row(); } keyboard.text(KeyboardConstants.BACK, `${Callback.DISCOUNT.MENU}:0`); return keyboard; } static selectType(): InlineKeyboard { return new InlineKeyboard() .text('٪ درصدی', `${Callback.DISCOUNT.SELECT_TYPE}:PERCENTAGE`) .text('💰 مبلغ ثابت', `${Callback.DISCOUNT.SELECT_TYPE}:FIXED`) .row() .text(KeyboardConstants.BACK, `${Callback.DISCOUNT.MENU}:0`); } static skipMaxUsage(): InlineKeyboard { return new InlineKeyboard().text('رد شدن، بدون محدودیت', Callback.DISCOUNT.SKIP_MAX_USAGE); } static skipExpiry(): InlineKeyboard { return new InlineKeyboard().text('رد شدن، بدون انقضا', Callback.DISCOUNT.SKIP_EXPIRY); } static confirmCreate(): InlineKeyboard { return new InlineKeyboard() .text('✅ تایید و ثبت', Callback.DISCOUNT.CONFIRM_CREATE) .row() .text('❌ انصراف', Callback.DISCOUNT.CANCEL_CREATE); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\keyboard.constants.ts ############################################################ . export const KeyboardConstants = { BACK: '⬅️ بازگشت', ADD_CHANNEL: '➕ افزودن کانال', CHANNEL_LIST: '📋 لیست کانال‌ها', DELETE: '🗑 حذف', CHANNELS: '📺 مدیریت کانال‌ها', PLANS: '💳 مدیریت پلن‌ها', ADD_PLAN: '➕ ایجاد پلن', PLAN_LIST: '📋 لیست پلن‌ها', BUY_SUBSCRIPTION: '🛒 خرید اشتراک جدید', } as const;. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\member.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class MemberKeyboard { static channelList(channels: { id: string; title: string }[]): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const c of channels) { keyboard.text(`📺 ${c.title}`, `${Callback.MEMBER.CHANNEL}:${c.id}`).row(); } keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); return keyboard; } static submenu(channelId: string): InlineKeyboard { return new InlineKeyboard() .text('🟢 اعضای فعال', `${Callback.MEMBER.ACTIVE}:${channelId}`) .row() .text('🔴 اعضای منقضی', `${Callback.MEMBER.EXPIRED}:${channelId}`) .row() .text('🔍 جستجو', `${Callback.MEMBER.SEARCH}:${channelId}`) .row() .text(KeyboardConstants.BACK, Callback.MEMBER.MENU); } static memberListWithRemove( members: { subscriptionId: string; label: string }[], backChannelId: string, page = 0, // 🔧 مقدار پیش‌فرض hasNextPage = false, // 🔧 مقدار پیش‌فرض ): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const m of members) { keyboard .text(m.label, 'noop') .row() .text('🗑 حذف عضو', `${Callback.MEMBER.REMOVE}:${m.subscriptionId}`) .text('🚫 بن', `${Callback.BAN.MEMBER_MENU}:${m.subscriptionId}`) // 🆕 .row(); } if (page > 0) { keyboard.text('⬅️ قبلی', `${Callback.MEMBER.ACTIVE}:${backChannelId}:${page - 1}`); } if (hasNextPage) { keyboard.text('➡️ بعدی', `${Callback.MEMBER.ACTIVE}:${backChannelId}:${page + 1}`); } if (page > 0 || hasNextPage) { keyboard.row(); } keyboard.text(KeyboardConstants.BACK, `${Callback.MEMBER.CHANNEL}:${backChannelId}`); return keyboard; } // static memberListWithRemove( // members: { subscriptionId: string; label: string }[], // backChannelId: string, // ): InlineKeyboard { // const keyboard = new InlineKeyboard(); // for (const m of members) { // keyboard // .text(m.label, 'noop') // .row() // .text('🗑 حذف عضو', `${Callback.MEMBER.REMOVE}:${m.subscriptionId}`) // .row(); // } // keyboard.text(KeyboardConstants.BACK, `${Callback.MEMBER.CHANNEL}:${backChannelId}`); // return keyboard; // } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\owner.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class OwnerKeyboard { static menu(): InlineKeyboard { return new InlineKeyboard() .text(KeyboardConstants.CHANNELS, Callback.CHANNEL.MENU) .row() .text(KeyboardConstants.PLANS, Callback.PLAN.MENU) .row() .text('👥 مدیریت کاربران', Callback.MEMBER.MENU) .row() .text('📦 مدیریت اشتراک‌ها', Callback.SUBSCRIPTION.MENU) // 🆕 طبق بخش ۱۰ سند .row() .text('📊 آمار و گزارشات', Callback.OWNER.STATS) .row() .text('💳 کارت‌های بانکی', Callback.BANK_CARD.MENU) // 🆕 .row() .text('🎟 کدهای تخفیف', Callback.DISCOUNT.MENU) .row() .text('📸 ارسال فیش پرداخت', Callback.PAYMENT_REQUEST.MY_LIST) // 🆕 .row() .text('📥 درخواست‌های در انتظار تایید', Callback.PAYMENT_REQUEST.RECEIVER_LIST) .row() .text('📖 راهنما', Callback.HELP.MENU) // 🆕 .row() .text('📢 پیام‌رسانی گروهی', Callback.BROADCAST.MENU); } // static menu(): InlineKeyboard { // return new InlineKeyboard() // .text(KeyboardConstants.CHANNELS, Callback.CHANNEL.MENU) // .row() // .text(KeyboardConstants.PLANS, Callback.PLAN.MENU) // .row() // .text('👥 مدیریت کاربران', Callback.MEMBER.MENU) // .row() // .text('🎟 کدهای تخفیف', Callback.DISCOUNT.MENU) // .row() // .text('📊 آمار و گزارشات', Callback.OWNER.STATS) // .row() // .text('📢 پیام‌رسانی گروهی', Callback.BROADCAST.MENU) // } // static revenueRangeSelect(): InlineKeyboard { // return new InlineKeyboard() // .text('امروز', `${Callback.OWNER.STATS_REVENUE}:daily`) // .text('این ماه', `${Callback.OWNER.STATS_REVENUE}:monthly`) // .text('امسال', `${Callback.OWNER.STATS_REVENUE}:yearly`) // .row() // .text(KeyboardConstants.BACK, Callback.OWNER.STATS); // } static statsOverview(): InlineKeyboard { return new InlineKeyboard() .text('امروز', `${Callback.OWNER.STATS_REVENUE}:daily`) .text('این هفته', `${Callback.OWNER.STATS_REVENUE}:weekly`) .row() .text('این ماه', `${Callback.OWNER.STATS_REVENUE}:monthly`) .text('امسال', `${Callback.OWNER.STATS_REVENUE}:yearly`) .row() .text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); } static revenueDetail(): InlineKeyboard { return new InlineKeyboard() .text(KeyboardConstants.BACK, Callback.OWNER.STATS); // 🔧 بازگشت به نمای کلی آمار } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\payment-approval.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; export class PaymentApprovalKeyboard { static approveReject(paymentRequestId: string): InlineKeyboard { return new InlineKeyboard() .text('✅ تایید پرداخت', `${Callback.PAYMENT_REQUEST.APPROVE}:${paymentRequestId}`) .text('❌ رد پرداخت', `${Callback.PAYMENT_REQUEST.REJECT}:${paymentRequestId}`); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\plan.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class PlanKeyboard { // static menu(): InlineKeyboard { // return new InlineKeyboard() // .text(KeyboardConstants.ADD_PLAN, Callback.PLAN.CREATE) // .row() // .text(KeyboardConstants.PLAN_LIST, Callback.PLAN.LIST) // .row() // .text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); // } static list( plans: { id: string; title: string; price: number; channel: { title: string } }[], page: number, hasNextPage: boolean, ): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const p of plans) { keyboard .text(`💳 ${p.channel.title} — ${p.title} — ${p.price.toLocaleString()}`, 'noop') .row() .text('🗑 حذف', `${Callback.PLAN.DELETE}:${p.id}`) .row(); } if (page > 0) keyboard.text('⬅️ قبلی', `${Callback.PLAN.MENU}:${page - 1}`); if (hasNextPage) keyboard.text('➡️ بعدی', `${Callback.PLAN.MENU}:${page + 1}`); if (page > 0 || hasNextPage) keyboard.row(); keyboard.text(KeyboardConstants.ADD_PLAN, Callback.PLAN.CREATE).row(); keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); return keyboard; } static selectChannel(channels: { id: string; title: string }[]): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const channel of channels) { keyboard.text(`📺 ${channel.title}`, `${Callback.PLAN.SELECT_CHANNEL}:${channel.id}`).row(); } keyboard.text(KeyboardConstants.BACK, `${Callback.PLAN.MENU}:0`); // 🔧 return keyboard; } static backToList(): InlineKeyboard { return new InlineKeyboard().text('💳 لیست پلن‌ها', `${Callback.PLAN.MENU}:0`); } static confirmCreate(): InlineKeyboard { return new InlineKeyboard() .text('✅ تایید و ثبت', Callback.PLAN.CONFIRM_CREATE) .row() // .text('✏️ عنوان', Callback.PLAN.EDIT_CREATE_TITLE) // .text('✏️ مدت', Callback.PLAN.EDIT_CREATE_DURATION) // .text('✏️ قیمت', Callback.PLAN.EDIT_CREATE_PRICE) .row() .text('❌ انصراف', Callback.PLAN.CANCEL_CREATE); } // static viewList(): InlineKeyboard { // return new InlineKeyboard() // .text(KeyboardConstants.ADD_PLAN, Callback.PLAN.CREATE) // .row() // .text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); // } static durationUnits(): InlineKeyboard { return new InlineKeyboard() .text('روز', `${Callback.PLAN.DURATION_UNIT}:DAY`).row() .text('ماه', `${Callback.PLAN.DURATION_UNIT}:MONTH`).row() .text('سال', `${Callback.PLAN.DURATION_UNIT}:YEAR`).row() .text(KeyboardConstants.BACK, `${Callback.PLAN.MENU}:0`); } static viewList(plans: { id: string; title: string; price: number }[]): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const plan of plans) { keyboard .text(`💳 ${plan.title}`, 'noop') // فقط نمایشی، کلیک‌پذیر نیست عملاً .row() // .text('✏️ ویرایش', `${Callback.PLAN.EDIT}:${plan.id}`) .text('🗑 حذف', `${Callback.PLAN.DELETE}:${plan.id}`) .row(); } keyboard.text(KeyboardConstants.ADD_PLAN, Callback.PLAN.CREATE).row(); keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); return keyboard; } static editFields(planId: string): InlineKeyboard { return new InlineKeyboard() .text('✏️ عنوان', `${Callback.PLAN.EDIT_FIELD}:${planId}:title`) .row() .text('💰 قیمت', `${Callback.PLAN.EDIT_FIELD}:${planId}:price`) .row() .text('⏳ مدت زمان', `${Callback.PLAN.EDIT_FIELD}:${planId}:durationValue`) .row() .text(KeyboardConstants.BACK, Callback.PLAN.LIST); } static deleteConfirm(planId: string): InlineKeyboard { return new InlineKeyboard() .text('✅ بله، حذف شود', `${Callback.PLAN.DELETE_CONFIRM}:${planId}`) .text('❌ انصراف', `${Callback.PLAN.MENU}:0`); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\subscription.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; import { KeyboardConstants } from './keyboard.constants'; export class SubscriptionKeyboard { // 🔧 جایگزین menu() قدیمی — دیگه صفحه‌ی واسط نیست، خودِ لیست صفحه‌بندی‌شده‌ست static list( subs: { id: string; channelTitle: string }[], page: number, hasNextPage: boolean, ): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const s of subs) { keyboard.text(`📺 ${s.channelTitle}`, 'noop').row(); keyboard .text('🔄 تمدید', `${Callback.SUBSCRIPTION.RENEW}:${s.id}`) .text('⬆️ ارتقا پلن', `${Callback.SUBSCRIPTION.UPGRADE}:${s.id}`) .text('🔗', `${Callback.SUBSCRIPTION.GET_INVITE_LINK}:${s.id}`) .row(); } if (page > 0) keyboard.text('⬅️ قبلی', `${Callback.SUBSCRIPTION.MENU}:${page - 1}`); if (hasNextPage) keyboard.text('➡️ بعدی', `${Callback.SUBSCRIPTION.MENU}:${page + 1}`); if (page > 0 || hasNextPage) keyboard.row(); keyboard.text(KeyboardConstants.BACK, Callback.NAVIGATION.BACK); return keyboard; } static channelList(channels: { id: string; title: string }[]): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const c of channels) { keyboard.text(`📺 ${c.title}`, `${Callback.SUBSCRIPTION.BROWSE_PLANS}:${c.id}`).row(); } keyboard.text(KeyboardConstants.BACK, `${Callback.SUBSCRIPTION.MENU}:0`); return keyboard; } static planList(plans: { id: string; title: string; price: number }[]): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const p of plans) { keyboard .text(`💳 ${p.title} — ${p.price.toLocaleString()} تومان`, `${Callback.SUBSCRIPTION.SELECT_PLAN}:${p.id}`) .row(); } keyboard.text(KeyboardConstants.BACK, Callback.SUBSCRIPTION.BROWSE_CHANNELS); return keyboard; } static upgradePlanList( subscriptionId: string, plans: { id: string; title: string; price: number }[], ): InlineKeyboard { const keyboard = new InlineKeyboard(); for (const p of plans) { keyboard .text(`💳 ${p.title} — ${p.price.toLocaleString()} تومان`, `${Callback.SUBSCRIPTION.SELECT_UPGRADE_PLAN}:${subscriptionId}:${p.id}`) .row(); } keyboard.text(KeyboardConstants.BACK, `${Callback.SUBSCRIPTION.MENU}:0`); return keyboard; } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\keyboards\user.keyboard.ts ############################################################ . import { InlineKeyboard } from 'grammy'; import { Callback } from '../constants/callback'; export class UserKeyboard { static menu(): InlineKeyboard { return new InlineKeyboard() .text('🛒 مدیریت اشتراک', Callback.SUBSCRIPTION.MENU) .row() .text('➕ ثبت کانال', Callback.USER.CREATE_CHANNEL) .row() .text('📸 ارسال فیش پرداخت', Callback.PAYMENT_REQUEST.MY_LIST) .row() .text('📖 راهنما', Callback.HELP.MENU); // 🆕 } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\middlewares\auth.middleware.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { NextFunction } from 'grammy'; import { TelegramContext } from '../context/telegram.context'; import { UsersService } from 'src/modules/users/services/users.service'; import { OwnerService } from 'src/modules/owners/services/owner.service'; import { ChannelService } from 'src/modules/channels/services/channel.service'; import { BotSubscriptionService } from 'src/modules/bot-subscriptions/services/bot-subscription.service'; @Injectable() export class AuthMiddleware { private readonly logger = new Logger(AuthMiddleware.name); constructor( private readonly usersService: UsersService, private readonly ownerService: OwnerService, private readonly channelService: ChannelService, private readonly botSubscriptionService: BotSubscriptionService, ) { } async use(ctx: TelegramContext, next: NextFunction): Promise { if (!ctx.from) { // بعضی آپدیت‌های تلگرام (مثل آپدیت‌های خودِ کانال) فرستنده‌ی // مشخصی ندارن؛ برای این‌ها کاربری resolve نمی‌کنیم. await next(); return; } try { const user = await this.usersService.resolveTelegramUser({ telegramId: String(ctx.from.id), username: ctx.from.username, firstName: ctx.from.first_name, lastName: ctx.from.last_name, }); ctx.currentUser = user; const displayName = user.firstName ?? user.username ?? 'کاربر'; const owner = await this.ownerService.resolveOrCreateOwner(user.id, displayName); ctx.currentOwner = owner; ctx.ownerChannelCount = await this.channelService.countByOwner(owner.id); // 🆕 طبق سند بخش ۲ قدم ۶: فقط اگر واقعاً owner واقعی است (کانال دارد) // زحمت چک‌کردن اشتراک ربات را به خودمان می‌دهیم. ctx.botSubscriptionActive = await this.botSubscriptionService.isActive(owner.id); } catch (error) { this.logger.error('AuthMiddleware failed to resolve user', error); // طبق سند بخش ۲: خطا لاگ میشه ولی زنجیره متوقف نمیشه؛ // هر Handler خودش باید نبود ctx.currentUser رو چک کنه. } await next(); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\middlewares\mandatory-join.middleware.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { NextFunction } from 'grammy'; import { InlineKeyboard } from 'grammy'; import { TelegramContext } from '../context/telegram.context'; import { Callback } from '../constants/callback'; @Injectable() export class MandatoryJoinMiddleware { private readonly logger = new Logger(MandatoryJoinMiddleware.name); async use(ctx: TelegramContext, next: NextFunction): Promise { const channelId = process.env.MANDATORY_JOIN_CHANNEL_ID; // فقط نقش USER چک می‌شه؛ Owner/Admin معاف‌اند if (!channelId || !ctx.currentUser || ctx.role !== 'USER') { await next(); return; } // دکمه‌ی «بررسی عضویت» خودش باید همیشه رد بشه // if (ctx.callbackQuery?.data === Callback.JOIN.CHECK) { // await next(); // return; // } try { const member = await ctx.api.getChatMember(channelId, ctx.from!.id); const isMember = ['member', 'administrator', 'creator'].includes(member.status); if (isMember) { await next(); return; } } catch (error) { // اگه چک عضویت با خطا مواجه شد (مثلاً ربات ادمین کانال نیست)، عبور بده تا ربات کامل قفل نشه this.logger.error('Failed to check mandatory join membership', error); await next(); return; } const channelUsername = channelId.startsWith('@') ? channelId.slice(1) : null; const keyboard = new InlineKeyboard(); if (channelUsername) keyboard.url('📢 عضویت در کانال', `https://t.me/${channelUsername}`).row(); keyboard.text('✅ عضو شدم، بررسی مجدد', Callback.JOIN.CHECK); const text = '⛔ برای استفاده از ربات، ابتدا باید عضو کانال ما شوید.'; console.log(ctx.callbackQuery); if (ctx.callbackQuery) { await ctx.answerCallbackQuery({ text, show_alert: true }).catch(() => undefined); // await ctx.reply(text, { reply_markup: keyboard }).catch(() => undefined); } else { await ctx.reply(text, { reply_markup: keyboard }).catch(() => undefined); } // next() صدا زده نمی‌شه — زنجیره اینجا متوقف می‌شه } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\middlewares\role.middleware.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { NextFunction } from 'grammy'; import { TelegramContext } from '../context/telegram.context'; import { BotSubscriptionKeyboard } from '../keyboards/bot-subscription.keyboard'; @Injectable() export class RoleMiddleware { private readonly logger = new Logger(RoleMiddleware.name); async use(ctx: TelegramContext, next: NextFunction): Promise { if (!ctx.currentOwner) { await next(); return; } const isRealOwner = (ctx.ownerChannelCount ?? 0) > 0; const subscriptionInactive = !ctx.botSubscriptionActive; if (isRealOwner && subscriptionInactive) { const data = ctx.callbackQuery?.data ?? ''; const isBotSubscriptionFlow = data.startsWith('bot-sub:'); // 🆕 عکس در این سیستم فقط برای فیش پرداخته — همیشه باید عبور کنه، // وگرنه فلوی تمدید اشتراک ربات (که خودش نیازمند همین گیت هست) قفل می‌شه. const isPaymentReceiptPhoto = Boolean(ctx.message?.photo?.length); if (!isBotSubscriptionFlow && !isPaymentReceiptPhoto) { const warningText = [ '⛔ اشتراک ربات شما منقضی یا پرداخت‌نشده است.', 'برای استفاده از پنل، ابتدا اشتراک ربات را از سازنده ربات تهیه یا تمدید کنید.', ].join('\n'); try { if (ctx.callbackQuery) { await ctx.answerCallbackQuery({ text: warningText, show_alert: true }); await ctx.editMessageText(warningText, { reply_markup: BotSubscriptionKeyboard.renewButton(), }).catch(() => undefined); } else if (ctx.hasTextMessage()) { await ctx.reply(warningText, { reply_markup: BotSubscriptionKeyboard.renewButton(), }); } } catch (error) { this.logger.error('Failed to send bot-subscription-locked warning', error); await ctx.answerCallbackQuery().catch(() => undefined); } return; } } await next(); } } // import { Injectable, Logger } from '@nestjs/common'; // import { NextFunction } from 'grammy'; // import { TelegramContext } from '../context/telegram.context'; // import { BotSubscriptionKeyboard } from '../keyboards/bot-subscription.keyboard'; // @Injectable() // export class RoleMiddleware { // private readonly logger = new Logger(RoleMiddleware.name); // async use(ctx: TelegramContext, next: NextFunction): Promise { // if (!ctx.currentOwner) { // await next(); // return; // } // const isRealOwner = (ctx.ownerChannelCount ?? 0) > 0; // const subscriptionInactive = !ctx.botSubscriptionActive; // if (isRealOwner && subscriptionInactive) { // const data = ctx.callbackQuery?.data ?? ''; // const isBotSubscriptionFlow = data.startsWith('bot-sub:'); // if (!isBotSubscriptionFlow) { // const warningText = [ // '⛔ اشتراک ربات شما منقضی یا پرداخت‌نشده است.', // 'برای استفاده از پنل، ابتدا اشتراک ربات را از سازنده ربات تهیه یا تمدید کنید.', // ].join('\n'); // try { // if (ctx.callbackQuery) { // await ctx.answerCallbackQuery({ text: warningText, show_alert: true }); // await ctx.editMessageText(warningText, { // reply_markup: BotSubscriptionKeyboard.renewButton(), // }).catch(() => undefined); // } else if (ctx.hasTextMessage()) { // await ctx.reply(warningText, { // reply_markup: BotSubscriptionKeyboard.renewButton(), // }); // } // } catch (error) { // // 🆕 مثل بقیه‌ی Middleware ها: خطا لاگ می‌شه، زنجیره همچنان // // متوقف می‌مونه (چون این یه گیت قانونیه، نه یه خطای غیرمنتظره) // this.logger.error('Failed to send bot-subscription-locked warning', error); // await ctx.answerCallbackQuery().catch(() => undefined); // 🆕 حداقل spinner قطع بشه // } // return; // } // } // await next(); // } // }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\middlewares\session.middleware.ts ############################################################ . import { Injectable, Logger } from '@nestjs/common'; import { NextFunction } from 'grammy'; import { TelegramContext } from '../context/telegram.context'; import { TelegramSessionService } from '../services/telegram-session.service'; import { TelegramSessionData } from '../session/telegram-session.interface'; @Injectable() export class SessionMiddleware { private readonly logger = new Logger(SessionMiddleware.name); constructor(private readonly sessionService: TelegramSessionService) { } async use(ctx: TelegramContext, next: NextFunction): Promise { // بدون کاربر شناسایی‌شده، نمی‌شه Session ساخت if (!ctx.userId) { await next(); return; } try { const raw = await this.sessionService.getOrCreate(ctx.userId); ctx.session = { ...raw, data: (raw.data as TelegramSessionData) ?? {} }; } catch (error) { this.logger.error('Unable to initialize telegram session', error); } await next(); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\registry\telegram.registry.ts ############################################################ . import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { DiscoveryService } from '@nestjs/core'; import { Bot } from 'grammy'; import { TelegramService } from '../services/telegram.service'; import { TelegramContext } from '../context/telegram.context'; import { TelegramHandler } from '../dispatcher/handler.interface'; import { TELEGRAM_COMMAND_METADATA } from '../decorators/telegram-command.decorator'; import { TELEGRAM_CALLBACK_METADATA, TelegramCallbackOptions, } from '../decorators/telegram-callback.decorator'; import { AuthMiddleware } from '../middlewares/auth.middleware'; import { RoleMiddleware } from '../middlewares/role.middleware'; import { SessionMiddleware } from '../middlewares/session.middleware'; import { MessageDispatcher } from '../dispatcher/message.dispatcher'; import { TelegramSessionStep } from '@prisma/client'; import { TELEGRAM_MESSAGE_STEP_METADATA } from '../decorators/telegram-message-step.decorator'; import { PhotoDispatcher } from '../dispatcher/photo.dispatcher'; import { TELEGRAM_PHOTO_STEP_METADATA } from '../decorators/telegram-photo-step.decorator'; import { MandatoryJoinMiddleware } from '../middlewares/mandatory-join.middleware'; @Injectable() export class TelegramRegistry implements OnModuleInit { private readonly logger = new Logger(TelegramRegistry.name); constructor( private readonly photoDispatcher: PhotoDispatcher, // 🆕 private readonly telegramService: TelegramService, private readonly discoveryService: DiscoveryService, private readonly authMiddleware: AuthMiddleware, private readonly roleMiddleware: RoleMiddleware, private readonly sessionMiddleware: SessionMiddleware, private readonly messageDispatcher: MessageDispatcher, private readonly mandatoryJoinMiddleware: MandatoryJoinMiddleware, ) { } async onModuleInit(): Promise { const bot = this.telegramService.getBot(); this.registerMiddlewares(bot); this.autoRegisterHandlers(bot); this.registerMessages(bot); this.registerErrorHandler(bot); } private registerMiddlewares(bot: Bot): void { bot.use((ctx, next) => this.authMiddleware.use(ctx, next)); bot.use((ctx, next) => this.mandatoryJoinMiddleware.use(ctx, next)); bot.use((ctx, next) => this.roleMiddleware.use(ctx, next)); bot.use((ctx, next) => this.sessionMiddleware.use(ctx, next)); } private autoRegisterHandlers(bot: Bot): void { const exactCallbacks = new Map(); const prefixCallbacks: { prefix: string; handler: TelegramHandler }[] = []; const seenSteps = new Set(); // 🆕 برای تشخیص تعارض Session Step for (const wrapper of this.discoveryService.getProviders()) { const instance = wrapper.instance; if (!instance || typeof instance !== 'object') continue; const ctor = instance.constructor; const command: string | undefined = Reflect.getMetadata(TELEGRAM_COMMAND_METADATA, ctor); if (command) { const handler = instance as TelegramHandler; bot.command(command, async (ctx) => handler.execute(ctx)); this.logger.log(`Registered command "/${command}" → ${ctor.name}`); } const photoStep: TelegramSessionStep | undefined = Reflect.getMetadata( TELEGRAM_PHOTO_STEP_METADATA, ctor, ); if (photoStep) { this.photoDispatcher.register(photoStep, instance as TelegramHandler); this.logger.log(`Registered photo step "${photoStep}" → ${ctor.name}`); } const callbackOptions: TelegramCallbackOptions[] | undefined = Reflect.getMetadata(TELEGRAM_CALLBACK_METADATA, ctor); if (callbackOptions?.length) { const handler = instance as TelegramHandler; for (const option of callbackOptions) { if (option.matchType === 'startsWith') { const alreadyExists = prefixCallbacks.some((p) => p.prefix === option.pattern); if (alreadyExists) { this.logger.warn( `Duplicate prefix callback "${option.pattern}" registered again by ${ctor.name} — قبلی override نمی‌شه اما این تکرار احتمالاً اشتباهه، بررسی کن.`, ); continue; } prefixCallbacks.push({ prefix: option.pattern, handler }); } else { if (exactCallbacks.has(option.pattern)) { this.logger.warn( `Duplicate exact callback "${option.pattern}" — قبلی توسط ${ctor.name} override می‌شه. این معمولاً یعنی pattern رو دوبار تعریف کردی.`, ); } exactCallbacks.set(option.pattern, handler); } this.logger.log(`Registered callback "${option.pattern}" → ${ctor.name}`); } } const step: TelegramSessionStep | undefined = Reflect.getMetadata( TELEGRAM_MESSAGE_STEP_METADATA, ctor, ); if (step) { if (seenSteps.has(step)) { this.logger.warn(`Duplicate message step handler for "${step}" registered by ${ctor.name}`); } seenSteps.add(step); this.messageDispatcher.register(step, instance as TelegramHandler); this.logger.log(`Registered message step "${step}" → ${ctor.name}`); } } // 🆕 مرتب‌سازی بر اساس طول prefix (نزولی) — طولانی‌ترین/دقیق‌ترین pattern // همیشه اول چک می‌شه، صرف‌نظر از ترتیب discovery. این دقیقاً همون کاریه که // قبلاً هر Handler به‌صورت دستی و با ترتیب دکوریتورهاش انجام می‌داد. prefixCallbacks.sort((a, b) => b.prefix.length - a.prefix.length); for (const [pattern, handler] of exactCallbacks) { bot.callbackQuery(pattern, async (ctx) => { await this.executeWithAutoAnswer(ctx, handler); }); } if (prefixCallbacks.length) { bot.on('callback_query:data', async (ctx, next) => { const data = ctx.callbackQuery.data; const match = prefixCallbacks.find(({ prefix }) => data.startsWith(prefix)); if (match) { await this.executeWithAutoAnswer(ctx, match.handler); return; } await next(); }); } } private async executeWithAutoAnswer(ctx: TelegramContext, handler: TelegramHandler): Promise { let answered = false; const originalAnswer = ctx.answerCallbackQuery.bind(ctx); ctx.answerCallbackQuery = (async (...args: Parameters) => { answered = true; return originalAnswer(...args); }) as typeof ctx.answerCallbackQuery; try { await handler.execute(ctx); } finally { if (!answered) { await originalAnswer().catch(() => undefined); } } } private registerMessages(bot: Bot): void { bot.on('message:text', async (ctx) => { await this.messageDispatcher.dispatch(ctx); }); bot.on('message:photo', async (ctx) => { await this.photoDispatcher.dispatch(ctx); }); } private registerErrorHandler(bot: Bot): void { bot.catch((error) => { this.logger.error(error); }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\repository\telegram-session.repository.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { TelegramSessionStep, Prisma } from '@prisma/client'; @Injectable() export class TelegramSessionRepository { constructor(private readonly prisma: PrismaService) { } findByUserId(userId: string) { return this.prisma.telegramSession.findUnique({ where: { userId } }); } create(payload: { userId: string; step?: TelegramSessionStep; data?: Prisma.InputJsonValue }) { return this.prisma.telegramSession.create({ data: { userId: payload.userId, step: payload.step ?? 'IDLE', data: payload.data ?? {}, }, }); } update(userId: string, payload: { step?: TelegramSessionStep; data?: Prisma.InputJsonValue }) { return this.prisma.telegramSession.update({ where: { userId }, data: { step: payload.step, data: payload.data }, }); } reset(userId: string) { return this.prisma.telegramSession.update({ where: { userId }, data: { step: 'IDLE', data: {} }, }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\services\telegram-api.service.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { TelegramService } from './telegram.service'; import { InlineKeyboard } from 'node_modules/grammy/out/convenience/keyboard'; @Injectable() export class TelegramApiService { constructor(private readonly telegramService: TelegramService) { } async getChannel(chatId: string) { return this.telegramService.getBot().api.getChat(chatId); } async isBotAdmin(chatId: string): Promise { const bot = this.telegramService.getBot(); const me = await bot.api.getMe(); const member = await bot.api.getChatMember(chatId, me.id); return member.status === 'administrator' || member.status === 'creator'; } async leaveChannel(chatId: string): Promise { await this.telegramService.getBot().api.leaveChat(chatId); } async createSingleUseInviteLink(chatId: string, name: string): Promise { const bot = this.telegramService.getBot(); const link = await bot.api.createChatInviteLink(chatId, { name, creates_join_request: true, }); return link.invite_link; } async sendPhotoWithKeyboard(telegramId: string, fileId: string, caption: string, keyboard: InlineKeyboard): Promise { await this.telegramService.getBot().api.sendPhoto(telegramId, fileId, { caption, reply_markup: keyboard, }); } async sendMessage(telegramId: string, text: string): Promise { await this.telegramService.getBot().api.sendMessage(telegramId, text); } async sendMessageWithKeyboard(telegramId: string, text: string, keyboard: InlineKeyboard): Promise { await this.telegramService.getBot().api.sendMessage(telegramId, text, { reply_markup: keyboard }); } async approveChatJoinRequest(chatId: string, userId: number): Promise { await this.telegramService.getBot().api.approveChatJoinRequest(chatId, userId); } async declineChatJoinRequest(chatId: string, userId: number): Promise { await this.telegramService.getBot().api.declineChatJoinRequest(chatId, userId); } async banChatMember(chatId: string, userId: number): Promise { await this.telegramService.getBot().api.banChatMember(chatId, userId); } async unbanChatMember(chatId: string, userId: number): Promise { await this.telegramService.getBot().api.unbanChatMember(chatId, userId, { only_if_banned: true }); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\services\telegram-session.service.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { TelegramSessionRepository } from '../repository/telegram-session.repository'; import { TelegramSessionStep, Prisma } from '@prisma/client'; import { TelegramSessionData } from '../session/telegram-session.interface'; import { UsersService } from 'src/modules/users/services/users.service'; @Injectable() export class TelegramSessionService { constructor( private readonly repo: TelegramSessionRepository, private readonly usersService: UsersService, ) { } async getOrCreate(userId: string) { const existing = await this.repo.findByUserId(userId); if (existing) return existing; return this.repo.create({ userId, step: 'IDLE', data: {} }); } // 🎯 این متد قلب فلوی ثبت کانال است: وقتی رویداد my_chat_member میاد، // فقط telegramId کاربر رو داریم، نه userId داخلی؛ پس باید اول User رو // پیدا کنیم، بعد Session اون User رو. async findByTelegramUserId(telegramUserId: string) { const user = await this.usersService.findByTelegramId(telegramUserId); if (!user) return null; return this.repo.findByUserId(user.id); } async update(userId: string, payload: { step?: TelegramSessionStep; data?: TelegramSessionData }) { const jsonData = payload.data as unknown as Prisma.InputJsonValue | undefined; const existing = await this.repo.findByUserId(userId); if (!existing) { return this.repo.create({ userId, step: payload.step ?? 'IDLE', data: jsonData ?? {} }); } return this.repo.update(userId, { step: payload.step, data: jsonData }); } async reset(userId: string) { return this.repo.reset(userId); } // اسم مستعار برای reset — بعضی handlerها معنایی resetStep رو خواناتر می‌بینن async resetStep(userId: string) { return this.reset(userId); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\services\telegram.service.ts ############################################################ . import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Bot } from 'grammy'; import { TelegramContext } from '../context/telegram.context'; @Injectable() export class TelegramService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(TelegramService.name); private readonly bot: Bot; constructor(private readonly configService: ConfigService) { const token = this.configService.get('BOT_TOKEN'); if (!token) { throw new Error('BOT_TOKEN is missing'); } this.bot = new Bot(token, { ContextConstructor: TelegramContext, }); } getBot(): Bot { return this.bot; } async onModuleInit(): Promise { // ⚠️ عمداً await نمی‌کنیم — bot.start() تا وقتی ربات متوقف نشه resolve نمیشه // (long polling یعنی یه حلقه‌ی بی‌نهایت). اگه await کنیم، بقیه‌ی // سرویس‌های NestJS (از جمله TelegramRegistry که دستورات رو رجیستر می‌کنه) // هیچ‌وقت اجرا نمیشن. this.bot.start({ onStart: (info) => { this.logger.log(`Telegram bot started: @${info.username}`); }, }).catch((error) => { console.log(error); this.logger.error('Bot polling crashed', error); }); } async onModuleDestroy(): Promise { await this.bot.stop(); this.logger.log('Telegram bot stopped'); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\telegram\session\telegram-session.interface.ts ############################################################ . import { TelegramSessionStep } from '@prisma/client'; // فیلدهای بیشتر (planId, discountCode, ...) رو تو ماژول‌های بعدی // که واقعاً بهشون نیاز داریم، اضافه می‌کنیم. فعلاً فقط همینی که // برای فلوی ثبت کانال لازمه. export interface TelegramSessionData { channelId?: string; title?: string; durationUnit?: 'DAY' | 'MONTH' | 'YEAR'; durationValue?: number; channelUsername?: string; // 🆕 planId?: string;// برای ویرایش/حذف: کدوم پلن هدفه editField?: 'title' | 'price' | 'durationValue'; // کدوم فیلد داره ویرایش میشه selectedPlanId?: string; // 🆕 پلنی که کاربر برای خرید انتخاب کرده discountCode?: string; // 🆕 کد تخفیفی که تایید شده selectedBotPlanId?: string; price?: number; pendingPurchase?: { // 🆕 برای فلوی پرسیدن کد تخفیف قبل از ساخت درخواست پرداخت kind: 'CHANNEL_PLAN' | 'BOT_PLAN'; planId?: string; botPlanId?: string; subscriptionId?: string; renewOrUpgrade?: 'renew' | 'upgrade'; }; memberSearchChannelId?: string; channelGuideMessageId?: number; // 🆕 برای حذف پیام راهنما بعد از ثبت موفق newDiscountPlanId?: string; newDiscountCode?: string; newDiscountType?: 'PERCENTAGE' | 'FIXED'; newDiscountValue?: number; newDiscountMaxUsage?: number; newDiscountExpiresInDays?: number; broadcastChannelId?: string; broadcastPendingContent?: string; walletChargeTargetUserId?: string; pendingPaymentRequestId?: string; // درخواست پرداختی که منتظر فیش یا در حال رد شدنه upgradeSubscriptionId?: string; newDiscountScope?: 'CHANNEL_PLAN' | 'BOT_PLAN'; pendingCardNumber?: string; banTargetType?: 'OWNER' | 'USER'; // 🆕 banTargetId?: string; // 🆕 ownerId یا userId banChannelId?: string; // 🆕 فقط برای بن کاربر در یک کانال outageStartDate?: string; // 🆕 ISO string، مرحله‌ی بین دو تاریخ banDurationDays?: number; // 🆕 } export interface TelegramSession { id: string; userId: string; step: TelegramSessionStep; data: TelegramSessionData; createdAt?: Date; updatedAt?: Date; }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\users\users.module.ts ############################################################ . import { Module } from '@nestjs/common'; import { UsersService } from './services/users.service'; @Module({ providers: [UsersService], exports: [UsersService], }) export class UsersModule { }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\modules\users\services\users.service.ts ############################################################ . import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { getAdminTelegramIds } from 'src/modules/telegram/config/admin.config'; import { UserRole } from '@prisma/client'; export interface ResolveTelegramUserInput { telegramId: string; username?: string; firstName?: string; lastName?: string; } @Injectable() export class UsersService { constructor(private readonly prisma: PrismaService) { } async resolveTelegramUser(data: ResolveTelegramUserInput) { const role: UserRole = getAdminTelegramIds().includes(data.telegramId) ? 'ADMIN' : 'USER'; const existing = await this.prisma.user.findUnique({ where: { telegramId: data.telegramId }, }); if (!existing) { return this.prisma.user.create({ data: { ...data, role } }); } const hasChanged = existing.username !== data.username || existing.firstName !== data.firstName || existing.lastName !== data.lastName || existing.role !== role; if (!hasChanged) return existing; return this.prisma.user.update({ where: { telegramId: data.telegramId }, data: { username: data.username, firstName: data.firstName, lastName: data.lastName, role, }, }); } async findById(id: string) { return this.prisma.user.findUnique({ where: { id } }); } async searchGlobal(query: string, limit = 20) { return this.prisma.user.findMany({ where: { OR: [ { telegramId: query }, { username: { contains: query, mode: 'insensitive' } }, { firstName: { contains: query, mode: 'insensitive' } }, ], }, take: limit, orderBy: { createdAt: 'desc' }, }); } async findByTelegramId(telegramId: string) { return this.prisma.user.findUnique({ where: { telegramId } }); } async findAllPaginated(page = 0, pageSize = 20) { return this.prisma.user.findMany({ select: { id: true, telegramId: true, username: true, firstName: true, lastName: true, role: true }, orderBy: { createdAt: 'desc' }, skip: page * pageSize, take: pageSize, }); } async countAll(): Promise { return this.prisma.user.count(); } }. . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\prisma\prisma.module.ts ############################################################ . import { Global, Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; @Global() @Module({ providers: [PrismaService], exports: [PrismaService], }) export class PrismaModule {} . . ############################################################ FILE: D:\myProjects\vip-telegram-platform\apps\api\src\prisma\prisma.service.ts ############################################################ . import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { async onModuleInit() { await this.$connect(); } async onModuleDestroy() { await this.$disconnect(); } }. .