feat(messenger): store and serve telegram profiles
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MessengerConnection" ADD COLUMN "avatarFileId" TEXT,
|
||||||
|
ADD COLUMN "avatarFileUniqueId" TEXT,
|
||||||
|
ADD COLUMN "displayName" TEXT,
|
||||||
|
ADD COLUMN "username" TEXT;
|
||||||
|
|
||||||
@@ -155,6 +155,10 @@ model MessengerConnection {
|
|||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
type MessengerType
|
type MessengerType
|
||||||
channelId String
|
channelId String
|
||||||
|
displayName String?
|
||||||
|
username String?
|
||||||
|
avatarFileId String?
|
||||||
|
avatarFileUniqueId String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { sendLoginCodeEmail } from './mailer.js';
|
import { sendLoginCodeEmail } from './mailer.js';
|
||||||
import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js';
|
import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js';
|
||||||
import { dateTimeScalar, jsonScalar } from './scalars.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'];
|
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) {
|
async function resolveSelectedDeliveryAddress(context, userId, deliveryAddressId) {
|
||||||
const normalizedAddressId = normalizeOptionalText(deliveryAddressId);
|
const normalizedAddressId = normalizeOptionalText(deliveryAddressId);
|
||||||
|
|
||||||
@@ -274,6 +295,9 @@ export const resolvers = {
|
|||||||
CounterpartyProfile: {
|
CounterpartyProfile: {
|
||||||
isComplete: (profile) => isCounterpartyProfileComplete(profile),
|
isComplete: (profile) => isCounterpartyProfileComplete(profile),
|
||||||
},
|
},
|
||||||
|
MessengerConnection: {
|
||||||
|
avatarAvailable: (connection) => Boolean(connection.avatarFileId),
|
||||||
|
},
|
||||||
DeliveryAddress: {
|
DeliveryAddress: {
|
||||||
isDefault: (address) => Boolean(address.isDefault),
|
isDefault: (address) => Boolean(address.isDefault),
|
||||||
},
|
},
|
||||||
@@ -308,10 +332,13 @@ export const resolvers = {
|
|||||||
|
|
||||||
myMessengerConnections: async (_, __, context) => {
|
myMessengerConnections: async (_, __, context) => {
|
||||||
const user = requireUser(context);
|
const user = requireUser(context);
|
||||||
return context.prisma.messengerConnection.findMany({
|
const connections = await context.prisma.messengerConnection.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
return Promise.all(
|
||||||
|
connections.map((connection) => enrichMessengerConnectionProfile(context.prisma, connection)),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
myNotificationHistory: async (_, { channel, limit }, context) => {
|
myNotificationHistory: async (_, { channel, limit }, context) => {
|
||||||
@@ -523,12 +550,22 @@ export const resolvers = {
|
|||||||
channelId: login.messengerConnection.channelId,
|
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: {
|
create: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: login.messengerConnection.type,
|
type: login.messengerConnection.type,
|
||||||
channelId: login.messengerConnection.channelId,
|
channelId: login.messengerConnection.channelId,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
displayName: login.messengerConnection.displayName ?? null,
|
||||||
|
username: login.messengerConnection.username ?? null,
|
||||||
|
avatarFileId: login.messengerConnection.avatarFileId ?? null,
|
||||||
|
avatarFileUniqueId: login.messengerConnection.avatarFileUniqueId ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ type MessengerConnection {
|
|||||||
userId: ID!
|
userId: ID!
|
||||||
type: MessengerType!
|
type: MessengerType!
|
||||||
channelId: String!
|
channelId: String!
|
||||||
|
displayName: String
|
||||||
|
username: String
|
||||||
|
avatarAvailable: Boolean!
|
||||||
isActive: Boolean!
|
isActive: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
@@ -20,6 +22,7 @@ import { buildContext } from './context.js';
|
|||||||
import { sendMessengerMessage } from './messenger.js';
|
import { sendMessengerMessage } from './messenger.js';
|
||||||
import { prisma } from './prisma-client.js';
|
import { prisma } from './prisma-client.js';
|
||||||
import { resolvers } from './resolvers.js';
|
import { resolvers } from './resolvers.js';
|
||||||
|
import { telegramApi, telegramFileUrl } from './telegram.js';
|
||||||
|
|
||||||
const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
|
const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8');
|
||||||
|
|
||||||
@@ -88,6 +91,19 @@ function normalizeRedirectPath(value) {
|
|||||||
return redirectPath;
|
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) {
|
async function resolveAuthenticatedUserFromRequest(req) {
|
||||||
const authToken = extractAuthTokenFromRequest(req);
|
const authToken = extractAuthTokenFromRequest(req);
|
||||||
const auth = verifyAccessToken(authToken);
|
const auth = verifyAccessToken(authToken);
|
||||||
@@ -182,6 +198,7 @@ app.post('/bot/messenger-login', async (req, res) => {
|
|||||||
messengerConnection: {
|
messengerConnection: {
|
||||||
type: channel,
|
type: channel,
|
||||||
channelId,
|
channelId,
|
||||||
|
...normalizeTelegramProfile(req.body?.profile),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const frontendUrl = (
|
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(
|
app.use(
|
||||||
'/graphql',
|
'/graphql',
|
||||||
expressMiddleware(server, {
|
expressMiddleware(server, {
|
||||||
|
|||||||
64
src/telegram.js
Normal file
64
src/telegram.js
Normal file
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user