diff --git a/prisma/migrations/0005_add_messenger_profile_fields/migration.sql b/prisma/migrations/0005_add_messenger_profile_fields/migration.sql new file mode 100644 index 0000000..5aecda9 --- /dev/null +++ b/prisma/migrations/0005_add_messenger_profile_fields/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "MessengerConnection" ADD COLUMN "avatarFileId" TEXT, +ADD COLUMN "avatarFileUniqueId" TEXT, +ADD COLUMN "displayName" TEXT, +ADD COLUMN "username" TEXT; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a012542..be391b5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -155,6 +155,10 @@ model MessengerConnection { user User @relation(fields: [userId], references: [id]) type MessengerType channelId String + displayName String? + username String? + avatarFileId String? + avatarFileUniqueId String? isActive Boolean @default(true) createdAt DateTime @default(now()) diff --git a/src/resolvers.js b/src/resolvers.js index b1cf767..df558a7 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -11,6 +11,7 @@ import { import { sendLoginCodeEmail } from './mailer.js'; import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js'; import { dateTimeScalar, jsonScalar } from './scalars.js'; +import { fetchTelegramConnectionProfile } from './telegram.js'; const ACTIVE_ORDER_STATUSES = ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']; @@ -133,6 +134,26 @@ function withDeliveryAddressDefaultFlag(address, defaultDeliveryAddressId) { }; } +async function enrichMessengerConnectionProfile(prisma, connection) { + if ( + connection.type !== 'TELEGRAM' || + (connection.displayName && connection.username && connection.avatarFileId) + ) { + return connection; + } + + const profile = await fetchTelegramConnectionProfile(connection.channelId); + return prisma.messengerConnection.update({ + where: { id: connection.id }, + data: { + displayName: profile.displayName, + username: profile.username, + avatarFileId: profile.avatarFileId, + avatarFileUniqueId: profile.avatarFileUniqueId, + }, + }); +} + async function resolveSelectedDeliveryAddress(context, userId, deliveryAddressId) { const normalizedAddressId = normalizeOptionalText(deliveryAddressId); @@ -274,6 +295,9 @@ export const resolvers = { CounterpartyProfile: { isComplete: (profile) => isCounterpartyProfileComplete(profile), }, + MessengerConnection: { + avatarAvailable: (connection) => Boolean(connection.avatarFileId), + }, DeliveryAddress: { isDefault: (address) => Boolean(address.isDefault), }, @@ -308,10 +332,13 @@ export const resolvers = { myMessengerConnections: async (_, __, context) => { const user = requireUser(context); - return context.prisma.messengerConnection.findMany({ + const connections = await context.prisma.messengerConnection.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'desc' }, }); + return Promise.all( + connections.map((connection) => enrichMessengerConnectionProfile(context.prisma, connection)), + ); }, myNotificationHistory: async (_, { channel, limit }, context) => { @@ -523,12 +550,22 @@ export const resolvers = { channelId: login.messengerConnection.channelId, }, }, - update: { isActive: true }, + 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, }, }); } diff --git a/src/schema.graphql b/src/schema.graphql index 462fb0d..a0c2b32 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -141,6 +141,9 @@ type MessengerConnection { userId: ID! type: MessengerType! channelId: String! + displayName: String + username: String + avatarAvailable: Boolean! isActive: Boolean! } diff --git a/src/server.js b/src/server.js index f9a48f4..aa82fd4 100644 --- a/src/server.js +++ b/src/server.js @@ -1,6 +1,8 @@ import 'dotenv/config'; import { readFileSync } from 'node:fs'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import bodyParser from 'body-parser'; import cors from 'cors'; @@ -20,6 +22,7 @@ import { buildContext } from './context.js'; import { sendMessengerMessage } from './messenger.js'; import { prisma } from './prisma-client.js'; import { resolvers } from './resolvers.js'; +import { telegramApi, telegramFileUrl } from './telegram.js'; const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8'); @@ -88,6 +91,19 @@ function normalizeRedirectPath(value) { return redirectPath; } +function normalizeTelegramProfile(profile) { + if (!profile || typeof profile !== 'object') { + return null; + } + + 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, + }; +} + async function resolveAuthenticatedUserFromRequest(req) { const authToken = extractAuthTokenFromRequest(req); const auth = verifyAccessToken(authToken); @@ -182,6 +198,7 @@ app.post('/bot/messenger-login', async (req, res) => { messengerConnection: { type: channel, channelId, + ...normalizeTelegramProfile(req.body?.profile), }, }); const frontendUrl = ( @@ -225,6 +242,53 @@ app.post('/bot/messenger-login', async (req, res) => { }); }); +app.get('/messenger/avatar/:connectionId', async (req, res) => { + const user = await resolveAuthenticatedUserFromRequest(req); + if (!user) { + res.status(401).json({ error: 'Authentication required.' }); + return; + } + + const connectionId = String(req.params.connectionId || '').trim(); + if (!connectionId) { + res.status(400).json({ error: 'Connection id is required.' }); + return; + } + + const connection = await prisma.messengerConnection.findFirst({ + where: { + id: connectionId, + userId: user.id, + type: 'TELEGRAM', + isActive: true, + }, + }); + + if (!connection?.avatarFileId) { + res.status(404).json({ error: 'Telegram avatar not found.' }); + return; + } + + const file = await telegramApi('getFile', { + file_id: connection.avatarFileId, + }); + + if (!file.file_path) { + res.status(404).json({ error: 'Telegram avatar file path not found.' }); + return; + } + + const fileResponse = await fetch(telegramFileUrl(file.file_path)); + if (!fileResponse.ok || !fileResponse.body) { + res.status(502).json({ error: 'Unable to fetch Telegram avatar.' }); + return; + } + + res.setHeader('content-type', fileResponse.headers.get('content-type') || 'image/jpeg'); + res.setHeader('cache-control', 'private, max-age=300'); + await pipeline(Readable.fromWeb(fileResponse.body), res); +}); + app.use( '/graphql', expressMiddleware(server, { diff --git a/src/telegram.js b/src/telegram.js new file mode 100644 index 0000000..a5e0feb --- /dev/null +++ b/src/telegram.js @@ -0,0 +1,64 @@ +function requireTelegramBotToken() { + const token = String(process.env.TELEGRAM_BOT_TOKEN || '').trim(); + if (!token) { + throw new Error('TELEGRAM_BOT_TOKEN is required.'); + } + return token; +} + +export async function telegramApi(method, payload) { + const response = await fetch(`https://api.telegram.org/bot${requireTelegramBotToken()}/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + if (!response.ok || !data.ok) { + throw new Error(`Telegram ${method} failed: ${JSON.stringify(data).slice(0, 280)}`); + } + + return data.result; +} + +function normalizeTelegramDisplayName(value) { + const displayName = String(value || '').trim(); + return displayName ? displayName : null; +} + +function normalizeTelegramUsername(value) { + const username = String(value || '').trim().replace(/^@+/, ''); + return username ? username : null; +} + +function buildTelegramDisplayName(chat) { + const firstName = String(chat?.first_name || '').trim(); + const lastName = String(chat?.last_name || '').trim(); + const displayName = `${firstName} ${lastName}`.trim(); + return normalizeTelegramDisplayName(displayName); +} + +export async function fetchTelegramConnectionProfile(channelId) { + const chat = await telegramApi('getChat', { + chat_id: channelId, + }); + + const profilePhotos = await telegramApi('getUserProfilePhotos', { + user_id: Number(channelId), + limit: 1, + }); + + const photoSizes = Array.isArray(profilePhotos.photos?.[0]) ? profilePhotos.photos[0] : []; + const bestPhoto = photoSizes.at(-1); + + return { + displayName: buildTelegramDisplayName(chat), + username: normalizeTelegramUsername(chat?.username), + avatarFileId: normalizeTelegramDisplayName(bestPhoto?.file_id), + avatarFileUniqueId: normalizeTelegramDisplayName(bestPhoto?.file_unique_id), + }; +} + +export function telegramFileUrl(filePath) { + return `https://api.telegram.org/file/bot${requireTelegramBotToken()}/${filePath}`; +}