diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3304555..96598db 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -93,7 +93,7 @@ model User { followings Follow[] @relation(name: "followings") notifications Notification[] notificationSettings NotificationSettings? - telegramId String? @default("telegram_id") + telegramId String? @unique @map("telegram_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/core/config/graphql.config.ts b/backend/src/core/config/graphql.config.ts index f6a1701..4650fc1 100644 --- a/backend/src/core/config/graphql.config.ts +++ b/backend/src/core/config/graphql.config.ts @@ -3,13 +3,14 @@ import { ApolloDriverConfig } from '@nestjs/apollo' import { ConfigService } from '@nestjs/config' import { Request, Response } from 'express' import * as path from 'node:path' +import { ProcessEnv } from '../../shared/types/env' type ContextType = { req: Request res: Response } -export function getGraphQLConfig(configService: ConfigService): ApolloDriverConfig { +export function getGraphQLConfig(configService: ConfigService): ApolloDriverConfig { return { playground: isDev(configService), path: configService.getOrThrow('GRAPHQL_PREFIX'), diff --git a/backend/src/core/config/livekit.config.ts b/backend/src/core/config/livekit.config.ts index 94dd652..2905820 100644 --- a/backend/src/core/config/livekit.config.ts +++ b/backend/src/core/config/livekit.config.ts @@ -1,7 +1,8 @@ import { TypeLiveKitOptions } from '@/src/module/libs/livekit/type/livekit.type' import { ConfigService } from '@nestjs/config' +import { ProcessEnv } from '../../shared/types/env' -export function getLiveKitConfig(configService: ConfigService): TypeLiveKitOptions { +export function getLiveKitConfig(configService: ConfigService): TypeLiveKitOptions { return { apiSecret: configService.getOrThrow('LIVEKIT_API_SECRET'), apiKey: configService.getOrThrow('LIVEKIT_API_KEY'), diff --git a/backend/src/core/config/mailer.config.ts b/backend/src/core/config/mailer.config.ts index b5bb5e2..cab9c72 100644 --- a/backend/src/core/config/mailer.config.ts +++ b/backend/src/core/config/mailer.config.ts @@ -1,7 +1,8 @@ -import { MailerOptions } from '@nestjs-modules/mailer' -import { ConfigService } from '@nestjs/config' +import type { ProcessEnv } from '../../shared/types/env'; +import type { ConfigService } from '@nestjs/config'; +import type { MailerOptions } from '@nestjs-modules/mailer'; -export function getMailConfig(configService: ConfigService): MailerOptions { +export function getMailConfig(configService: ConfigService): MailerOptions { return { transport: { host: configService.getOrThrow('MAIL_HOST'), @@ -15,5 +16,5 @@ export function getMailConfig(configService: ConfigService): MailerOptions { defaults: { from: `"TeaStream" ${configService.getOrThrow('MAIL_LOGIN')}`, }, - } + }; } diff --git a/backend/src/core/config/telegraf.config.ts b/backend/src/core/config/telegraf.config.ts new file mode 100644 index 0000000..e0848c5 --- /dev/null +++ b/backend/src/core/config/telegraf.config.ts @@ -0,0 +1,9 @@ +import { ConfigService } from '@nestjs/config' +import { TelegrafModuleOptions } from 'nestjs-telegraf' +import { ProcessEnv } from '../../shared/types/env'; + +export function getTelegrafOptions(configService: ConfigService): TelegrafModuleOptions { + return { + token: configService.getOrThrow('TELEGRAM_BOT_TOKEN'), + } +} diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index 620605b..486e105 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -15,6 +15,7 @@ import { FollowModule } from '@/src/module/follow/follow.module' import { LiveKitModule } from '@/src/module/libs/livekit/livekit.module' import { MailModule } from '@/src/module/libs/mail/mail.module' import { StorageModule } from '@/src/module/libs/storage/storage.module' +import { TelegramModule } from '@/src/module/libs/telegram/telegram.module' import { NotificationModule } from '@/src/module/notification/notification.module' import { IngressModule } from '@/src/module/stream/ingress/ingress.module' import { StreamModule } from '@/src/module/stream/stream.module' @@ -49,6 +50,7 @@ import { RedisModule } from './redis/redis.module' useFactory: getLiveKitConfig, inject: [ConfigService], }), + TelegramModule, AccountModule, SessionModule, VerificationModule, diff --git a/backend/src/core/redis/redis.service.ts b/backend/src/core/redis/redis.service.ts index 9c0d9ad..a79fa24 100644 --- a/backend/src/core/redis/redis.service.ts +++ b/backend/src/core/redis/redis.service.ts @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import Redis from 'ioredis' +import { ProcessEnv } from '../../shared/types/env'; @Injectable() export class RedisService extends Redis { constructor( - private readonly configService: ConfigService, + private readonly configService: ConfigService, ) { super(configService.getOrThrow('REDIS_URI')) } diff --git a/backend/src/main.ts b/backend/src/main.ts index f46b2b4..70cfcc7 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -16,21 +16,21 @@ async function bootstrap() { const config = app.get(ConfigService) const redis = app.get(RedisService) - app.use(cookieParser(config.getOrThrow('COOKIE_SECRET'))) - app.use(config.getOrThrow('GRAPHQL_PREFIX'), graphqlUploadExpress()) + app.use(cookieParser(config.getOrThrow('COOKIE_SECRET'))) + app.use(config.getOrThrow('GRAPHQL_PREFIX'), graphqlUploadExpress()) app.useGlobalPipes(new ValidationPipe({ transform: true, })) app.use(session({ - secret: config.getOrThrow('SESSION_SECRET'), - name: config.getOrThrow('SESSION_NAME'), + secret: config.getOrThrow('SESSION_SECRET'), + name: config.getOrThrow('SESSION_NAME'), resave: false, saveUninitialized: false, cookie: { - domain: config.getOrThrow('SESSION_DOMAIN'), - maxAge: ms(config.getOrThrow('SESSION_MAX_AGE')), + domain: config.getOrThrow('SESSION_DOMAIN'), + maxAge: ms(config.getOrThrow('SESSION_MAX_AGE')), httpOnly: parseBoolean(config.getOrThrow('SESSION_HTTP_ONLY')), secure: parseBoolean(config.getOrThrow('SESSION_SECURE')), sameSite: 'lax', @@ -42,7 +42,7 @@ async function bootstrap() { })) app.enableCors({ - origin: config.getOrThrow('ALLOWED_ORIGIN'), + origin: config.getOrThrow('ALLOWED_ORIGIN'), credentials: true, exposedHeaders: ['set-cookie'], }) diff --git a/backend/src/module/auth/deactivate/deactivate.service.ts b/backend/src/module/auth/deactivate/deactivate.service.ts index 380f748..f8cd031 100644 --- a/backend/src/module/auth/deactivate/deactivate.service.ts +++ b/backend/src/module/auth/deactivate/deactivate.service.ts @@ -1,7 +1,7 @@ import { PrismaService } from '@/src/core/prisma/prisma.service' import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input' import { MailService } from '@/src/module/libs/mail/mail.service' -import { SessionInfo } from '@/src/shared/types/session-metadata.types' +import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { generateToken } from '@/src/shared/util/generate-token.util' import { getSessionMetadata } from '@/src/shared/util/session-metadata.util' import { destroySession } from '@/src/shared/util/session.util' @@ -10,13 +10,15 @@ import { ConfigService } from '@nestjs/config' import { TokenType, User } from '@prisma/generated' import { verify } from 'argon2' import { Request } from 'express' +import { ProcessEnv } from '../../../shared/types/env' @Injectable() export class DeactivateService { constructor( private readonly prismaService: PrismaService, - private readonly configService: ConfigService, + private readonly configService: ConfigService, private readonly mailService: MailService, + private readonly telegramService: TelegramService ) { } @@ -89,6 +91,9 @@ export class DeactivateService { const metadata = getSessionMetadata(req, userAgent) await this.mailService.sendDeactivateToken(user.email, deactivateToken.token, metadata) + if (deactivateToken.user?.notificationSettings?.telegramNotifications && deactivateToken.user?.telegramId) { + await this.telegramService.sendDeactivateToken(deactivateToken.user.telegramId, deactivateToken.token, metadata) + } return true } } diff --git a/backend/src/module/auth/password-recovery/password-recovery.module.ts b/backend/src/module/auth/password-recovery/password-recovery.module.ts index 7fb51e5..15b21de 100644 --- a/backend/src/module/auth/password-recovery/password-recovery.module.ts +++ b/backend/src/module/auth/password-recovery/password-recovery.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; -import { PasswordRecoveryService } from './password-recovery.service'; -import { PasswordRecoveryResolver } from './password-recovery.resolver'; +import { Module } from '@nestjs/common' +import { PasswordRecoveryService } from './password-recovery.service' +import { PasswordRecoveryResolver } from './password-recovery.resolver' @Module({ providers: [PasswordRecoveryResolver, PasswordRecoveryService], diff --git a/backend/src/module/auth/password-recovery/password-recovery.service.ts b/backend/src/module/auth/password-recovery/password-recovery.service.ts index 42ff743..44401e3 100644 --- a/backend/src/module/auth/password-recovery/password-recovery.service.ts +++ b/backend/src/module/auth/password-recovery/password-recovery.service.ts @@ -2,6 +2,7 @@ import { PrismaService } from '@/src/core/prisma/prisma.service' import { NewPasswordInput } from '@/src/module/auth/password-recovery/inputs/new-password.input' import { ResetPasswordInput } from '@/src/module/auth/password-recovery/inputs/reset-password.input' import { MailService } from '@/src/module/libs/mail/mail.service' +import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { generateToken } from '@/src/shared/util/generate-token.util' import { getSessionMetadata } from '@/src/shared/util/session-metadata.util' import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' @@ -14,6 +15,7 @@ export class PasswordRecoveryService { constructor( private readonly prismaService: PrismaService, private readonly mailService: MailService, + private readonly telegramService: TelegramService ) { } @@ -21,6 +23,7 @@ export class PasswordRecoveryService { const { email } = input const user = await this.prismaService.user.findFirst({ where: { email }, + include: { notificationSettings: true }, }) if (!user) { throw new NotFoundException('Пользователь с такой почтой не найден') @@ -30,6 +33,10 @@ export class PasswordRecoveryService { const metadata = getSessionMetadata(req, userAgent) await this.mailService.sendPasswordResetToken(user.email, resetToken.token, metadata) + if (resetToken.user?.notificationSettings?.telegramNotifications && resetToken?.user.telegramId) { + await this.telegramService.sendPasswordResetToken(resetToken.user.telegramId, resetToken.token, metadata) + } + return true } diff --git a/backend/src/module/auth/session/session.service.ts b/backend/src/module/auth/session/session.service.ts index 5311d35..a39113e 100644 --- a/backend/src/module/auth/session/session.service.ts +++ b/backend/src/module/auth/session/session.service.ts @@ -16,13 +16,14 @@ import { verify } from 'argon2' import { Request } from 'express' import { SessionData } from 'express-session' import { TOTP } from 'otpauth' +import { ProcessEnv } from '../../../shared/types/env' @Injectable() export class SessionService { constructor( private readonly prismaService: PrismaService, private readonly redisService: RedisService, - private readonly configService: ConfigService, + private readonly configService: ConfigService, private readonly verificationService: VerificationService, ) {} diff --git a/backend/src/module/cron/cron.service.ts b/backend/src/module/cron/cron.service.ts index 1ac690a..7bd548e 100644 --- a/backend/src/module/cron/cron.service.ts +++ b/backend/src/module/cron/cron.service.ts @@ -1,6 +1,7 @@ import { PrismaService } from '@/src/core/prisma/prisma.service' import { MailService } from '@/src/module/libs/mail/mail.service' import { StorageService } from '@/src/module/libs/storage/storage.service' +import { TelegramService } from '@/src/module/libs/telegram/telegram.service' import { Injectable } from '@nestjs/common' import { Cron, CronExpression } from '@nestjs/schedule' @@ -10,6 +11,7 @@ export class CronService { private readonly prismaService: PrismaService, private readonly mailService: MailService, private readonly storageService: StorageService, + private readonly telegramService: TelegramService, ) { } @@ -25,11 +27,18 @@ export class CronService { lte: sevenDayAgo, }, }, + include: { + notificationSettings: true, + }, }) for (const user of deactivatedAccounts) { console.log('Deactivate user', user.name, user.email) await this.mailService.sendAccountDeletion(user.email) + if (user?.telegramId) { + await this.telegramService.sendAccountDeletionToken(user?.telegramId) + } + if (user.avatar) { await this.storageService.remove(user.avatar) } diff --git a/backend/src/module/follow/follow.service.ts b/backend/src/module/follow/follow.service.ts index 0ce4166..f2d4094 100644 --- a/backend/src/module/follow/follow.service.ts +++ b/backend/src/module/follow/follow.service.ts @@ -1,4 +1,5 @@ import { PrismaService } from '@/src/core/prisma/prisma.service' +import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { NotificationService } from '@/src/module/notification/notification.service' import { ConflictException, Injectable, NotFoundException } from '@nestjs/common' import { User } from '@prisma/generated' @@ -8,6 +9,7 @@ export class FollowService { constructor( private readonly prismaService: PrismaService, private readonly notificationService: NotificationService, + private readonly telegramService: TelegramService, ) { } @@ -68,6 +70,9 @@ export class FollowService { if (follow.following.notificationSettings?.siteNotifications) { await this.notificationService.createNewFollowing(follow.following.id, follow.follower) } + if (follow.following.notificationSettings?.telegramNotifications && follow.following.telegramId) { + await this.telegramService.sendNewFollowing(follow.following.telegramId, follow.follower) + } return follow } diff --git a/backend/src/module/libs/mail/mail.service.ts b/backend/src/module/libs/mail/mail.service.ts index 1eda845..1138889 100644 --- a/backend/src/module/libs/mail/mail.service.ts +++ b/backend/src/module/libs/mail/mail.service.ts @@ -3,6 +3,7 @@ import { DeactivateTemplate } from '@/src/module/libs/mail/templates/deactivate. import PasswordRecoveryTemplate from '@/src/module/libs/mail/templates/password-recovery.template' import { SessionInfo } from '@/src/shared/types/session-metadata.types' import { Token } from '@prisma/generated' +import { ProcessEnv } from '../../../shared/types/env'; import VerificationTemplate from './templates/verification.template' import { MailerService } from '@nestjs-modules/mailer' import { Injectable } from '@nestjs/common' @@ -12,7 +13,7 @@ import { render } from '@react-email/components' @Injectable() export class MailService { constructor( - private readonly configService: ConfigService, + private readonly configService: ConfigService, private readonly mailerService: MailerService, ) {} diff --git a/backend/src/module/libs/storage/storage.service.ts b/backend/src/module/libs/storage/storage.service.ts index 407c2fa..6ca06c1 100644 --- a/backend/src/module/libs/storage/storage.service.ts +++ b/backend/src/module/libs/storage/storage.service.ts @@ -7,6 +7,7 @@ import { } from '@aws-sdk/client-s3' import { BadRequestException, Injectable } from '@nestjs/common' import { ConfigService } from '@nestjs/config' +import { ProcessEnv } from '../../../shared/types/env'; @Injectable() export class StorageService { @@ -14,7 +15,7 @@ export class StorageService { private readonly bucket: string constructor( - private readonly configService: ConfigService, + private readonly configService: ConfigService, ) { this.client = new S3Client({ endpoint: this.configService.getOrThrow('S3_ENDPOINT'), diff --git a/backend/src/module/libs/telegram/telegram.button.ts b/backend/src/module/libs/telegram/telegram.button.ts new file mode 100644 index 0000000..d0091e5 --- /dev/null +++ b/backend/src/module/libs/telegram/telegram.button.ts @@ -0,0 +1,17 @@ +import { Markup } from 'telegraf' + +export const BUTTONS = { + authSuccess: Markup.inlineKeyboard([ + [ + Markup.button.callback('📜 Мои подписки', 'follows'), + Markup.button.callback('👤 Просмотреть профиль', 'me'), + ], + [Markup.button.url('🌐 На сайт', 'https://teastream.ru')], + ]), + profile: Markup.inlineKeyboard([ + Markup.button.url( + '⚙️ Настройки аккаунта', + 'https://teastream.ru/dashboard/settings', + ), + ]), +} diff --git a/backend/src/module/libs/telegram/telegram.message.ts b/backend/src/module/libs/telegram/telegram.message.ts new file mode 100644 index 0000000..b2bcfc0 --- /dev/null +++ b/backend/src/module/libs/telegram/telegram.message.ts @@ -0,0 +1,74 @@ +import { SessionInfo } from '@/src/shared/types/session-metadata.types' +import type { User } from '@prisma/generated' + +export const MESSAGES = { + welcome: + '👋 Добро пожаловать в TeaStream Bot!\n\n' + + 'Чтобы получать уведомления и улучшить ваш опыт использования платформы, давайте свяжем ваш Telegram аккаунт с TeaStream.\n\n' + + 'Нажмите на кнопку ниже и перейдите в раздел Уведомления, чтобы завершить настройку.', + authSuccess: '🎉 Вы успешно авторизовались и Telegram аккаунт связан с TeaStream!\n\n', + invalidToken: '❌ Недействительный или просроченный токен.', + profile: (user: User, followersCount: number) => '👤 Профиль пользователя:\n\n' + + `👤 Имя пользователя: ${user.name}\n` + + `📧 Email: ${user.email}\n` + + `👥 Количество подписчиков: ${followersCount}\n` + + `📝 О себе: ${user.bio ?? 'Не указано'}\n\n` + + '🔧 Нажмите на кнопку ниже, чтобы перейти к настройкам профиля.', + follows: (user: User) => + `📺 ${user.name}`, + resetPassword: (token: string, metadata: SessionInfo) => + `🔒 Сброс пароля\n\n` + + `Вы запросили сброс пароля для вашей учетной записи на платформе TeaStream.\n\n` + + `Чтобы создать новый пароль, пожалуйста, перейдите по следующей ссылке:\n\n` + + `Сбросить пароль\n\n` + + `📅 Дата запроса: ${new Date().toLocaleDateString()} в ${new Date().toLocaleTimeString()}\n\n` + + `🖥️ Информация о запросе:\n\n` + + `🌍 Расположение: ${metadata.location.country}, ${metadata.location.city}\n` + + `📱 Операционная система: ${metadata.device.os}\n` + + `🌐 Браузер: ${metadata.device.browser}\n` + + `💻 IP-адрес: ${metadata.ip}\n\n` + + `Если вы не делали этот запрос, просто проигнорируйте это сообщение.\n\n` + + `Спасибо за использование TeaStream! 🚀`, + deactivate: (token: string, metadata: SessionInfo) => + `⚠️ Запрос на деактивацию аккаунта\n\n` + + `Вы инициировали процесс деактивации вашего аккаунта на платформе Teastream.\n\n` + + `Для завершения операции, пожалуйста, подтвердите свой запрос, введя следующий код подтверждения:\n\n` + + `Код подтверждения: ${token}\n\n` + + `📅 Дата запроса: ${new Date().toLocaleDateString()} в ${new Date().toLocaleTimeString()}\n\n` + + `🖥️ Информация о запросе:\n\n` + + `• 🌍 Расположение: ${metadata.location.country}, ${metadata.location.city}\n` + + `• 📱 Операционная система: ${metadata.device.os}\n` + + `• 🌐 Браузер: ${metadata.device.browser}\n` + + `• 💻 IP-адрес: ${metadata.ip}\n\n` + + `Что произойдет после деактивации?\n\n` + + `1. Вы автоматически выйдете из системы и потеряете доступ к аккаунту.\n` + + `2. Если вы не отмените деактивацию в течение 7 дней, ваш аккаунт будет безвозвратно удален со всей вашей информацией, данными и подписками.\n\n` + + `⏳ Обратите внимание: Если в течение 7 дней вы передумаете, вы можете обратиться в нашу поддержку для восстановления доступа к вашему аккаунту до момента его полного удаления.\n\n` + + `После удаления аккаунта восстановить его будет невозможно, и все данные будут потеряны без возможности восстановления.\n\n` + + `Если вы передумали, просто проигнорируйте это сообщение. Ваш аккаунт останется активным.\n\n` + + `Спасибо, что пользуетесь TeaStream! Мы всегда рады видеть вас на нашей платформе и надеемся, что вы останетесь с нами. 🚀\n\n` + + `С уважением,\n` + + `Команда TeaStream`, + accountDeleted: + `⚠️ Ваш аккаунт был полностью удалён.\n\n` + + `Ваш аккаунт был полностью стерт из базы данных Teastream. Все ваши данные и информация были удалены безвозвратно. ❌\n\n` + + `🔒 Вы больше не будете получать уведомления в Telegram и на почту.\n\n` + + `Если вы захотите вернуться на платформу, вы можете зарегистрироваться по следующей ссылке:\n` + + `Зарегистрироваться на Teastream\n\n` + + `Спасибо, что были с нами! Мы всегда будем рады видеть вас на платформе. 🚀\n\n` + + `С уважением,\n` + + `Команда TeaStream`, + streamStart: (channel: User) => + `📡 На канале ${channel.displayName} началась трансляция!\n\n` + + `Смотрите здесь: Перейти к трансляции`, + newFollowing: (follower: User, followersCount: number) => + `У вас новый подписчик!\n\nЭто пользователь ${follower.displayName}\n\nИтоговое количество подписчиков на вашем канале: ${followersCount}`, + enableTwoFactor: + `🔐 Обеспечьте свою безопасность!\n\n` + + `Включите двухфакторную аутентификацию в настройках аккаунта.`, + verifyChannel: + `🎉 Поздравляем! Ваш канал верифицирован\n\n` + + `Мы рады сообщить, что ваш канал теперь верифицирован, и вы получили официальный значок.\n\n` + + `Значок верификации подтверждает подлинность вашего канала и улучшает доверие зрителей.\n\n` + + `Спасибо, что вы с нами и продолжаете развивать свой канал вместе с TeaStream!`, +} diff --git a/backend/src/module/libs/telegram/telegram.module.ts b/backend/src/module/libs/telegram/telegram.module.ts new file mode 100644 index 0000000..ed73a50 --- /dev/null +++ b/backend/src/module/libs/telegram/telegram.module.ts @@ -0,0 +1,19 @@ +import { getTelegrafOptions } from '@/src/core/config/telegraf.config' +import { Global, Module } from '@nestjs/common' +import { ConfigModule, ConfigService } from '@nestjs/config' +import { TelegrafModule } from 'nestjs-telegraf' +import { TelegramService } from './telegram.service' + +@Global() +@Module({ + imports: [ + TelegrafModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: getTelegrafOptions, + }), + ], + providers: [TelegramService], + exports: [TelegramService], +}) +export class TelegramModule {} diff --git a/backend/src/module/libs/telegram/telegram.service.ts b/backend/src/module/libs/telegram/telegram.service.ts new file mode 100644 index 0000000..d6ec536 --- /dev/null +++ b/backend/src/module/libs/telegram/telegram.service.ts @@ -0,0 +1,159 @@ +import { SessionInfo } from '@/src/shared/types/session-metadata.types' +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { + Action, + Command, Ctx, Start, Update, +} from 'nestjs-telegraf' +import { Context, Telegraf } from 'telegraf' + +import { PrismaService } from '@/src/core/prisma/prisma.service' +import { BUTTONS } from '@/src/module/libs/telegram/telegram.button' +import { MESSAGES } from '@/src/module/libs/telegram/telegram.message' +import { TokenType, User } from '@prisma/generated' + +import { ProcessEnv } from '../../../shared/types/env' + +@Update() +@Injectable() +export class TelegramService extends Telegraf { + private readonly _token: string + + constructor( + private readonly prismaService: PrismaService, + private readonly configService: ConfigService, + ) { + super(configService.getOrThrow('TELEGRAM_BOT_TOKEN')) + this._token = configService.getOrThrow('TELEGRAM_BOT_TOKEN') + } + + @Start() + public async onStart(@Ctx() ctx: Context) { + const chatId = ctx.chat?.id.toString() + // @ts-ignore + const token = ctx.message.text.split(' ')[1] as string + + if (token) { + const authToken = await this.prismaService.token.findUnique({ + where: { + token, + type: TokenType.TELEGRAM_AUTH, + }, + }) + + if (!authToken?.userId) { + await ctx.reply('Токен не найден') + return + } + + const hasExpired = new Date(authToken.expiresIn) < new Date() + if (hasExpired) { + await ctx.reply(MESSAGES.invalidToken) + return + } + + await this.connectTelegram(authToken.userId, chatId!) + await this.prismaService.token.delete({ + where: { id: authToken.id }, + }) + + await ctx.replyWithHTML(MESSAGES.authSuccess, BUTTONS.authSuccess) + return + } + + const user = await this.findUserByChatId(chatId!) + if (user) { + return await this.onMe(ctx) + } + await ctx.replyWithHTML(MESSAGES.welcome, BUTTONS.profile) + } + + @Command('me') + @Action('me') + public async onMe(@Ctx() ctx: Context) { + const chatId = ctx.chat?.id.toString() + if (typeof chatId === 'undefined') { + throw new NotFoundException('Пользователь не найден') + } + + const user = await this.findUserByChatId(chatId) + if (!user) { + throw new NotFoundException('Пользователь не найден') + } + + const followersCount = await this.prismaService.follow.count({ + where: { followingId: user.id }, + }) + + await ctx.replyWithHTML(MESSAGES.profile(user, followersCount), BUTTONS.profile) + } + + @Command('follows') + @Action('follows') + public async onFollow(@Ctx() ctx: Context) { + const chatId = ctx.chat?.id + if (typeof chatId === 'undefined') { + throw new NotFoundException('Пользователь не найден') + } + + const user = await this.findUserByChatId(chatId.toString()) + if (!user) { + throw new NotFoundException('Пользователь не найден') + } + + const follows = await this.prismaService.follow.findMany({ + where: { followerId: user.id }, + include: { following: true }, + }) + + if (follows.length > 0) { + const followList = follows.map(follow => MESSAGES.follows(follow.following)).join('\n') + const message = `Каналы, на которые Вы подписаны\n\n${followList}` + await ctx.replyWithHTML(message) + return + } + + await ctx.replyWithHTML('❌ У Вас нет подписок') + } + + public async sendPasswordResetToken(chatId: string, token: string, metadata: SessionInfo) { + await this.telegram.sendMessage(chatId, MESSAGES.resetPassword(token, metadata), { parse_mode: 'HTML' }) + } + + public async sendDeactivateToken(chatId: string, token: string, metadata: SessionInfo) { + await this.telegram.sendMessage(chatId, MESSAGES.deactivate(token, metadata), { parse_mode: 'HTML' }) + } + + public async sendAccountDeletionToken(chatId: string) { + await this.telegram.sendMessage(chatId, MESSAGES.accountDeleted, { parse_mode: 'HTML' }) + } + + public async sendStreamStart(chatId: string, channel: User) { + await this.telegram.sendMessage(chatId, MESSAGES.streamStart(channel), { parse_mode: 'HTML' }) + } + + public async sendNewFollowing(chatId: string, follower: User) { + const user = await this.findUserByChatId(chatId) + if (!user) { + throw new NotFoundException('Пользователь не найден') + } + await this.telegram.sendMessage(chatId, MESSAGES.newFollowing(follower, user.followings.length), { parse_mode: 'HTML' }) + } + + private async connectTelegram(userId: string, chatId: string) { + return this.prismaService.user.update({ + where: { id: userId }, + data: { telegramId: chatId }, + }) + } + + private async findUserByChatId(chatId: string) { + return this.prismaService.user.findUnique({ + where: { telegramId: chatId }, + include: { + followings: true, + followers: true, + }, + }) + } +} diff --git a/backend/src/module/notification/notification.service.ts b/backend/src/module/notification/notification.service.ts index 5c9cfd8..41fb742 100644 --- a/backend/src/module/notification/notification.service.ts +++ b/backend/src/module/notification/notification.service.ts @@ -60,7 +60,7 @@ export class NotificationService { return { notificationSetting, - token: telegramAuthToken.token, + telegramAuthToken: telegramAuthToken.token, } } diff --git a/backend/src/module/stream/stream.service.ts b/backend/src/module/stream/stream.service.ts index b8a6562..36a6f1e 100644 --- a/backend/src/module/stream/stream.service.ts +++ b/backend/src/module/stream/stream.service.ts @@ -9,13 +9,14 @@ import { ConfigService } from '@nestjs/config' import * as Upload from 'graphql-upload/Upload' import { AccessToken } from 'livekit-server-sdk' import sharp from 'sharp' +import { ProcessEnv } from '../../shared/types/env'; @Injectable() export class StreamService { constructor( private readonly prismaService: PrismaService, private readonly storageService: StorageService, - private readonly configService: ConfigService, + private readonly configService: ConfigService, ) { } diff --git a/backend/src/module/webhook/webhook.service.ts b/backend/src/module/webhook/webhook.service.ts index 62724c0..ac30c05 100644 --- a/backend/src/module/webhook/webhook.service.ts +++ b/backend/src/module/webhook/webhook.service.ts @@ -1,5 +1,6 @@ import { PrismaService } from '@/src/core/prisma/prisma.service' import { LiveKitService } from '@/src/module/libs/livekit/livekit.service' +import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { NotificationService } from '@/src/module/notification/notification.service' import { Injectable } from '@nestjs/common' @@ -9,6 +10,7 @@ export class WebhookService { private prismaService: PrismaService, private liveKitService: LiveKitService, private readonly notificationService: NotificationService, + private readonly telegramService: TelegramService, ) { } @@ -41,6 +43,9 @@ export class WebhookService { if (follower.notificationSettings?.siteNotifications) { await this.notificationService.createStreamStart(follower.id, stream.user!) } + if (follower.notificationSettings?.telegramNotifications && follower.telegramId) { + await this.telegramService.sendStreamStart(follower.telegramId, stream.user!) + } } } diff --git a/backend/src/shared/types/env.d.ts b/backend/src/shared/types/env.d.ts new file mode 100644 index 0000000..79409a2 --- /dev/null +++ b/backend/src/shared/types/env.d.ts @@ -0,0 +1,50 @@ +import { StringValue } from '../util/ms.util' + +export interface ProcessEnv { + NODE_ENV: 'development' | 'production' + + APPLICATION_PORT: number + APPLICATION_URL: string + ALLOWED_ORIGIN: string + + COOKIE_SECRET: string + SESSION_SECRET: string + SESSION_NAME: string + SESSION_DOMAIN: string + SESSION_MAX_AGE: StringValue + SESSION_HTTP_ONLY: boolean + SESSION_SECURE: boolean + SESSION_FOLDER: string + + GRAPHQL_PREFIX: string + + POSTGRES_USER: string + POSTGRES_PASSWORD: string + POSTGRES_HOST: string + POSTGRES_PORT: number + POSTGRES_DATABASE: string + POSTGRES_URI: string + + REDIS_USER: string + REDIS_PASSWORD: string + REDIS_HOST: string + REDIS_PORT: number + REDIS_URI: string + + MAIL_HOST: string + MAIL_PORT: number + MAIL_LOGIN: string + MAIL_PASSWORD: string + + S3_ENDPOINT: string + S3_REGION: string + S3_ACCESS_KEY_ID: string + S3_SECRET_KEY_ID: string + S3_BUCKET_NAME: string + + LIVEKIT_URL: string + LIVEKIT_API_KEY: string + LIVEKIT_API_SECRET: string + + TELEGRAM_BOT_TOKEN: string +} diff --git a/backend/src/shared/util/generate-token.util.ts b/backend/src/shared/util/generate-token.util.ts index c3c3bcf..1a1b602 100644 --- a/backend/src/shared/util/generate-token.util.ts +++ b/backend/src/shared/util/generate-token.util.ts @@ -32,7 +32,11 @@ export async function generateToken(prismaService: PrismaService, user: User, ty }, }, include: { - user: true, + user: { + include: { + notificationSettings: true, + }, + }, }, }) } diff --git a/backend/src/shared/util/is-dev.util.ts b/backend/src/shared/util/is-dev.util.ts index 1a76320..6c3751e 100644 --- a/backend/src/shared/util/is-dev.util.ts +++ b/backend/src/shared/util/is-dev.util.ts @@ -1,10 +1,11 @@ import { ConfigService } from '@nestjs/config' import * as dotenv from 'dotenv' import * as process from 'node:process' +import { ProcessEnv } from '../types/env'; dotenv.config() -export function isDev(configService: ConfigService) { +export function isDev(configService: ConfigService) { return configService.getOrThrow('NODE_ENV') === 'development' } diff --git a/backend/src/shared/util/session.util.ts b/backend/src/shared/util/session.util.ts index a04d7ae..c3bb683 100644 --- a/backend/src/shared/util/session.util.ts +++ b/backend/src/shared/util/session.util.ts @@ -3,6 +3,7 @@ import { InternalServerErrorException } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { User } from '@prisma/generated' import { Request } from 'express' +import { ProcessEnv } from '../types/env' export function saveSession(req: Request, user: User, metadata: SessionInfo) { return new Promise((resolve, reject) => { @@ -20,7 +21,7 @@ export function saveSession(req: Request, user: User, metadata: SessionInfo) { }) } -export function destroySession(req: Request, configService: ConfigService) { +export function destroySession(req: Request, configService: ConfigService) { return new Promise((resolve, reject) => { req.session.destroy((error) => { if (error) {