diff --git a/src/messenger-connections.js b/src/messenger-connections.js new file mode 100644 index 0000000..bfb1520 --- /dev/null +++ b/src/messenger-connections.js @@ -0,0 +1,97 @@ +function normalizeOptionalText(value) { + const normalized = String(value || '').trim(); + return normalized || null; +} + +export function normalizeTelegramProfile(profile) { + if (!profile || typeof profile !== 'object') { + return { + displayName: null, + username: null, + avatarFileId: null, + avatarFileUniqueId: null, + }; + } + + return { + displayName: normalizeOptionalText(profile.displayName), + username: normalizeOptionalText(profile.username)?.replace(/^@+/, '') || null, + avatarFileId: normalizeOptionalText(profile.avatarFileId), + avatarFileUniqueId: normalizeOptionalText(profile.avatarFileUniqueId), + }; +} + +export function profileFromTelegramMiniAppUser(user) { + if (!user || typeof user !== 'object') { + return normalizeTelegramProfile(null); + } + + const firstName = String(user.first_name || '').trim(); + const lastName = String(user.last_name || '').trim(); + const displayName = `${firstName} ${lastName}`.trim(); + + return normalizeTelegramProfile({ + displayName, + username: user.username, + }); +} + +export async function upsertActiveMessengerConnection(prisma, { userId, type, channelId, profile = null }) { + const normalizedChannelId = String(channelId || '').trim(); + if (!userId || !type || !normalizedChannelId) { + throw new Error('userId, type and channelId are required to connect messenger.'); + } + + const normalizedProfile = type === 'TELEGRAM' + ? normalizeTelegramProfile(profile) + : normalizeTelegramProfile(null); + + return prisma.$transaction(async (tx) => { + await tx.messengerConnection.updateMany({ + where: { + userId, + type, + isActive: true, + NOT: { channelId: normalizedChannelId }, + }, + data: { isActive: false }, + }); + + await tx.messengerConnection.updateMany({ + where: { + type, + channelId: normalizedChannelId, + isActive: true, + NOT: { userId }, + }, + data: { isActive: false }, + }); + + return tx.messengerConnection.upsert({ + where: { + userId_type_channelId: { + userId, + type, + channelId: normalizedChannelId, + }, + }, + update: { + isActive: true, + displayName: normalizedProfile.displayName, + username: normalizedProfile.username, + avatarFileId: normalizedProfile.avatarFileId, + avatarFileUniqueId: normalizedProfile.avatarFileUniqueId, + }, + create: { + userId, + type, + channelId: normalizedChannelId, + isActive: true, + displayName: normalizedProfile.displayName, + username: normalizedProfile.username, + avatarFileId: normalizedProfile.avatarFileId, + avatarFileUniqueId: normalizedProfile.avatarFileUniqueId, + }, + }); + }); +} diff --git a/src/messenger.js b/src/messenger.js index 167792b..e3c0c12 100644 --- a/src/messenger.js +++ b/src/messenger.js @@ -6,6 +6,35 @@ function maskChannel(channelId) { return `${text.slice(0, 3)}***${text.slice(-3)}`; } +function buildTelegramButton(buttonUrl, buttonText) { + const url = String(buttonUrl || '').trim(); + if (!url) { + return null; + } + + const text = String(buttonText || '').trim() || 'Открыть кабинет'; + const miniAppBaseUrl = String( + process.env.TELEGRAM_MINI_APP_URL || + process.env.WEB_FRONTEND_URL || + process.env.NUXT_PUBLIC_SITE_URL || + '', + ).trim().replace(/\/$/, ''); + + if (miniAppBaseUrl && url.startsWith(miniAppBaseUrl)) { + return { + text, + web_app: { + url, + }, + }; + } + + return { + text, + url, + }; +} + async function sendTelegramMessage(channelId, message, options = {}) { const token = process.env.TELEGRAM_BOT_TOKEN; if (!token) { @@ -16,6 +45,7 @@ async function sendTelegramMessage(channelId, message, options = {}) { } try { + const button = buildTelegramButton(options.buttonUrl, options.buttonText); const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -23,14 +53,11 @@ async function sendTelegramMessage(channelId, message, options = {}) { chat_id: channelId, text: message, disable_web_page_preview: true, - ...(options.buttonUrl + ...(button ? { reply_markup: { inline_keyboard: [[ - { - text: options.buttonText || 'Открыть кабинет', - url: options.buttonUrl, - }, + button, ]], }, } @@ -121,7 +148,7 @@ export async function sendMessengerMessage({ type, channelId, message, buttonUrl }; } -export async function dispatchToUserConnections(prisma, userId, message) { +export async function dispatchToUserConnections(prisma, userId, message, options = {}) { const connections = await prisma.messengerConnection.findMany({ where: { userId, @@ -139,6 +166,8 @@ export async function dispatchToUserConnections(prisma, userId, message) { type: connection.type, channelId: connection.channelId, message, + buttonUrl: options.buttonUrl, + buttonText: options.buttonText, }); results.push({ diff --git a/src/resolvers.js b/src/resolvers.js index f4653a7..222e2a5 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -15,6 +15,7 @@ import { isManagerRole, } from './access.js'; import { sendLoginCodeEmail } from './mailer.js'; +import { upsertActiveMessengerConnection } from './messenger-connections.js'; import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js'; import { dateTimeScalar, jsonScalar } from './scalars.js'; import { fetchTelegramConnectionProfile } from './telegram.js'; @@ -281,6 +282,33 @@ function formatOrderStatusMessage(order, status, note) { return `Заказ ${order.code} изменил статус: ${status}.${suffix}`; } +function buildFrontendAppUrl(path) { + const baseUrl = String( + process.env.TELEGRAM_MINI_APP_URL || + process.env.WEB_FRONTEND_URL || + process.env.NUXT_PUBLIC_SITE_URL || + '', + ).trim().replace(/\/$/, ''); + + const normalizedPath = String(path || '').trim(); + if (!baseUrl || !normalizedPath.startsWith('/')) { + return null; + } + + return `${baseUrl}${normalizedPath}`; +} + +function buildUserOrderPath(orderId, role) { + const normalizedOrderId = String(orderId || '').trim(); + if (!normalizedOrderId) { + return ''; + } + + return isManagerRole(role) + ? `/client-orders/${normalizedOrderId}` + : `/orders/${normalizedOrderId}`; +} + async function notifyOrderStakeholders(context, order, status, note) { const recipients = [order.customerId, order.managerId].filter(Boolean); if (!recipients.length) { @@ -289,8 +317,22 @@ async function notifyOrderStakeholders(context, order, status, note) { const message = formatOrderStatusMessage(order, status, note); const uniqueRecipients = [...new Set(recipients)]; + const users = await context.prisma.user.findMany({ + where: { + id: { in: uniqueRecipients }, + }, + select: { + id: true, + role: true, + }, + }); + const userRoleMap = new Map(users.map((user) => [user.id, user.role])); + await Promise.allSettled( - uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message)), + uniqueRecipients.map((userId) => dispatchToUserConnections(context.prisma, userId, message, { + buttonUrl: buildFrontendAppUrl(buildUserOrderPath(order.id, userRoleMap.get(userId))), + buttonText: 'Открыть заказ', + })), ); } @@ -820,41 +862,11 @@ export const resolvers = { } if (login.messengerConnection) { - await context.prisma.messengerConnection.updateMany({ - where: { - userId: user.id, - type: login.messengerConnection.type, - isActive: true, - NOT: { channelId: login.messengerConnection.channelId }, - }, - data: { isActive: false }, - }); - - await context.prisma.messengerConnection.upsert({ - where: { - userId_type_channelId: { - userId: user.id, - type: login.messengerConnection.type, - channelId: login.messengerConnection.channelId, - }, - }, - update: { - isActive: true, - displayName: login.messengerConnection.displayName ?? null, - username: login.messengerConnection.username ?? null, - avatarFileId: login.messengerConnection.avatarFileId ?? null, - avatarFileUniqueId: login.messengerConnection.avatarFileUniqueId ?? null, - }, - create: { - userId: user.id, - type: login.messengerConnection.type, - channelId: login.messengerConnection.channelId, - isActive: true, - displayName: login.messengerConnection.displayName ?? null, - username: login.messengerConnection.username ?? null, - avatarFileId: login.messengerConnection.avatarFileId ?? null, - avatarFileUniqueId: login.messengerConnection.avatarFileUniqueId ?? null, - }, + await upsertActiveMessengerConnection(context.prisma, { + userId: user.id, + type: login.messengerConnection.type, + channelId: login.messengerConnection.channelId, + profile: login.messengerConnection, }); } diff --git a/src/server.js b/src/server.js index 2a5ecc0..78b87db 100644 --- a/src/server.js +++ b/src/server.js @@ -15,15 +15,18 @@ import { consumeMessengerStartSession, extractAuthTokenFromRequest, hasMessengerStartSession, + issueAccessToken, issueTemporaryLoginToken, verifyAccessToken, } from './auth.js'; import { canManagerAccessUser, isManagerRole } from './access.js'; import { buildContext } from './context.js'; +import { profileFromTelegramMiniAppUser, normalizeTelegramProfile, upsertActiveMessengerConnection } from './messenger-connections.js'; import { sendMessengerMessage } from './messenger.js'; import { prisma } from './prisma-client.js'; import { resolvers } from './resolvers.js'; import { telegramApi, telegramFileUrl } from './telegram.js'; +import { validateTelegramMiniAppInitData } from './telegram-mini-app.js'; const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8'); @@ -92,16 +95,28 @@ function normalizeRedirectPath(value) { return redirectPath; } -function normalizeTelegramProfile(profile) { - if (!profile || typeof profile !== 'object') { - return null; - } +function presentTelegramMiniAppUser(user) { + const firstName = String(user?.first_name || '').trim(); + const lastName = String(user?.last_name || '').trim(); return { - displayName: String(profile.displayName || '').trim() || null, - username: String(profile.username || '').trim().replace(/^@+/, '') || null, - avatarFileId: String(profile.avatarFileId || '').trim() || null, - avatarFileUniqueId: String(profile.avatarFileUniqueId || '').trim() || null, + id: String(user?.id || '').trim(), + firstName, + lastName: lastName || null, + username: String(user?.username || '').trim() || null, + languageCode: String(user?.language_code || '').trim() || null, + photoUrl: String(user?.photo_url || '').trim() || null, + displayName: `${firstName} ${lastName}`.trim() || firstName || 'Пользователь Telegram', + }; +} + +function presentAuthUser(user) { + return { + id: user.id, + email: user.email, + fullName: user.fullName, + role: user.role, + companyId: user.companyId ?? null, }; } @@ -150,6 +165,93 @@ app.post('/auth/messenger-start', async (req, res) => { }); }); +app.post('/auth/telegram-mini-app/session', async (req, res) => { + let telegram; + try { + telegram = validateTelegramMiniAppInitData(req.body?.initData); + } catch (error) { + res.status(401).json({ error: error.message }); + return; + } + + const channelId = String(telegram.user.id); + const connection = await prisma.messengerConnection.findFirst({ + where: { + type: 'TELEGRAM', + channelId, + isActive: true, + }, + include: { + user: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (!connection?.user) { + res.json({ + ok: true, + authenticated: false, + telegramUser: presentTelegramMiniAppUser(telegram.user), + }); + return; + } + + await upsertActiveMessengerConnection(prisma, { + userId: connection.user.id, + type: 'TELEGRAM', + channelId, + profile: profileFromTelegramMiniAppUser(telegram.user), + }); + + const session = issueAccessToken(connection.user.id); + res.json({ + ok: true, + authenticated: true, + accessToken: session.accessToken, + expiresAt: session.expiresAt.toISOString(), + user: presentAuthUser(connection.user), + telegramUser: presentTelegramMiniAppUser(telegram.user), + }); +}); + +app.post('/auth/telegram-mini-app/connect', async (req, res) => { + const user = await resolveAuthenticatedUserFromRequest(req); + if (!user) { + res.status(401).json({ error: 'Authentication required.' }); + return; + } + + let telegram; + try { + telegram = validateTelegramMiniAppInitData(req.body?.initData); + } catch (error) { + res.status(401).json({ error: error.message }); + return; + } + + const connection = await upsertActiveMessengerConnection(prisma, { + userId: user.id, + type: 'TELEGRAM', + channelId: telegram.user.id, + profile: profileFromTelegramMiniAppUser(telegram.user), + }); + + res.json({ + ok: true, + connection: { + id: connection.id, + userId: connection.userId, + type: connection.type, + channelId: connection.channelId, + displayName: connection.displayName, + username: connection.username, + avatarAvailable: Boolean(connection.avatarFileId), + isActive: connection.isActive, + }, + telegramUser: presentTelegramMiniAppUser(telegram.user), + }); +}); + app.post('/bot/messenger-login', async (req, res) => { const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN; const providedToken = req.body?.token; diff --git a/src/telegram-mini-app.js b/src/telegram-mini-app.js new file mode 100644 index 0000000..9713c82 --- /dev/null +++ b/src/telegram-mini-app.js @@ -0,0 +1,90 @@ +import crypto from 'node:crypto'; + +const TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS = Number(process.env.TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS ?? 60 * 60); + +function requireTelegramBotToken() { + const token = String(process.env.TELEGRAM_BOT_TOKEN || '').trim(); + if (!token) { + throw new Error('TELEGRAM_BOT_TOKEN is not configured.'); + } + return token; +} + +function timingSafeEqualHex(left, right) { + const leftBuffer = Buffer.from(String(left || ''), 'hex'); + const rightBuffer = Buffer.from(String(right || ''), 'hex'); + + if (!leftBuffer.length || leftBuffer.length !== rightBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function parseTelegramUser(rawUser) { + if (!rawUser) { + return null; + } + + const parsed = JSON.parse(rawUser); + if (!parsed || typeof parsed !== 'object' || !parsed.id) { + return null; + } + + return { + id: String(parsed.id), + first_name: String(parsed.first_name || '').trim(), + last_name: String(parsed.last_name || '').trim() || null, + username: String(parsed.username || '').trim() || null, + language_code: String(parsed.language_code || '').trim() || null, + photo_url: String(parsed.photo_url || '').trim() || null, + }; +} + +export function validateTelegramMiniAppInitData(initDataRaw) { + const initData = String(initDataRaw || '').trim(); + if (!initData) { + throw new Error('Telegram initData is required.'); + } + + const params = new URLSearchParams(initData); + const receivedHash = String(params.get('hash') || '').trim().toLowerCase(); + if (!receivedHash) { + throw new Error('Telegram initData hash is missing.'); + } + + const authDate = Number(params.get('auth_date')); + if (!Number.isFinite(authDate) || authDate <= 0) { + throw new Error('Telegram initData auth_date is invalid.'); + } + + const now = Math.floor(Date.now() / 1000); + if (now - authDate > TELEGRAM_MINI_APP_AUTH_MAX_AGE_SECONDS) { + throw new Error('Telegram initData is expired.'); + } + + const checkEntries = [...params.entries()] + .filter(([key]) => key !== 'hash' && key !== 'signature') + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, value]) => `${key}=${value}`); + const dataCheckString = checkEntries.join('\n'); + + const secretKey = crypto.createHmac('sha256', 'WebAppData').update(requireTelegramBotToken()).digest(); + const expectedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex'); + + if (!timingSafeEqualHex(expectedHash, receivedHash)) { + throw new Error('Telegram initData signature is invalid.'); + } + + const user = parseTelegramUser(params.get('user')); + if (!user?.id) { + throw new Error('Telegram user is missing in initData.'); + } + + return { + authDate: new Date(authDate * 1000), + queryId: String(params.get('query_id') || '').trim() || null, + startParam: String(params.get('start_param') || '').trim() || null, + user, + }; +}