Initial commit

This commit is contained in:
zavolo
2026-06-05 00:53:35 +03:00
commit e59e4424a4
7686 changed files with 352420 additions and 0 deletions
@@ -0,0 +1,536 @@
import express from 'express';
import multer from 'multer';
import { promises as fs } from 'fs';
import path from 'path';
import zlib from 'zlib';
import { promisify } from 'util';
import { ObjectId } from 'mongodb';
import minioHelper from '../utils/minioHelper.js';
import { generateFileReference, generateAccessHash, generateDocumentId } from '../utils/telegramIds.js';
const gunzip = promisify(zlib.gunzip);
const router = express.Router();
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = 'uploads/attributes';
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowedExts = ['.png', '.tgs'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Only .png and .tgs files are allowed'));
}
}
});
// Отдельная конфигурация multer для загрузки ZIP-архивов
const zipStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = 'uploads/zip';
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, `${file.fieldname}-${uniqueSuffix}.zip`);
}
});
const uploadZip = multer({
storage: zipStorage,
limits: { fileSize: 50 * 1024 * 1024 }, // до 50 МБ для ZIP
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ext === '.zip') {
cb(null, true);
} else {
cb(new Error('Only .zip files are allowed'));
}
}
});
// Приводит имя к единому виду для сопоставления: пробелы и дефисы -> подчёркивания,
// убирает известные префиксы и расширение
function normalizeName(name) {
return String(name || '')
.toLowerCase()
.replace(/\\+/g, '/')
.replace(/.*\//, '') // оставляем только имя файла
.replace(/\.(tgs|json|png)$/i, '') // убираем расширение
.replace(/^(models?|patterns?)_/, '') // убираем известные префиксы
.replace(/[\s-]+/g, '_') // пробелы и дефисы -> подчёркивание
.trim();
}
// Ищет в ZIP запись .tgs, устойчиво сопоставляя имена
function findZipEntryByName(zipEntries, targetName) {
if (!targetName) return { entry: null, reason: 'empty targetName' };
const normTarget = normalizeName(targetName);
const tgsEntries = zipEntries.filter(e => e.entryName.toLowerCase().endsWith('.tgs'));
// Заранее считаем кандидатов с нормализованными именами файлов
const candidates = tgsEntries.map(e => ({
entry: e,
base: e.entryName.replace(/\\+/g,'/').split('/').pop(),
})).map(x => ({ ...x, norm: normalizeName(x.base) }));
// 1) точное совпадение нормализованных имён
let found = candidates.find(c => c.norm === normTarget);
if (found) return { entry: found.entry, reason: 'norm exact' };
// 2) совпадение по началу строки (допускает суффиксы вроде _30)
found = candidates.find(c => c.norm.startsWith(normTarget));
if (found) return { entry: found.entry, reason: 'norm startsWith' };
// 3) ослабленное: отбрасываем хвост из _цифр у кандидата и сравниваем
found = candidates.find(c => c.norm.replace(/_[0-9]+$/, '') === normTarget);
if (found) return { entry: found.entry, reason: 'strip _digits equals' };
// 4) совпадение по началу после отбрасывания цифрового хвоста
found = candidates.find(c => c.norm.replace(/_[0-9]+$/, '').startsWith(normTarget));
if (found) return { entry: found.entry, reason: 'strip _digits startsWith' };
// 5) убираем подчёркивания у целевого имени и сравниваем по началу
const targetTight = normTarget.replace(/_/g, '');
found = candidates.find(c => c.norm.replace(/_/g,'').startsWith(targetTight));
if (found) return { entry: found.entry, reason: 'tight startsWith' };
return { entry: null, reason: `no match for ${normTarget}` };
}
const toJSON = (data) => {
return JSON.parse(JSON.stringify(data, (key, value) => {
if (typeof value === 'bigint') return value.toString();
return value;
}));
};
router.get('/', async (req, res) => {
try {
const attributeSets = await req.db.collection('StarGiftAttributeSets').find({}).sort({ _id: -1 }).toArray();
res.json(toJSON(attributeSets));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/', upload.fields([
{ name: 'model', maxCount: 1 },
{ name: 'pattern', maxCount: 1 }
]), async (req, res) => {
try {
const {
giftId,
name,
modelName,
patternName,
modelRarity,
patternRarity
} = req.body;
if (!giftId) return res.status(400).json({ error: 'Gift ID is required' });
if (!name) return res.status(400).json({ error: 'Name is required' });
const attributeSet = {
_id: Date.now().toString(),
GiftId: parseInt(giftId),
Name: name,
ModelName: modelName || name + ' Model',
PatternName: patternName || name + ' Pattern',
Model: null,
Pattern: null,
CreatedAt: new Date()
};
// Модель (TGS)
if (req.files.model && req.files.model[0]) {
const modelFile = req.files.model[0];
const documentId = generateDocumentId();
const accessHash = generateAccessHash();
const fileReference = generateFileReference();
const fileBuffer = await fs.readFile(modelFile.path);
try {
const decompressed = await gunzip(fileBuffer);
JSON.parse(decompressed.toString('utf8'));
} catch (error) {
try {
JSON.parse(fileBuffer.toString('utf8'));
} catch (jsonError) {
await fs.unlink(modelFile.path);
return res.status(400).json({ error: 'Invalid TGS file for model' });
}
}
await minioHelper.uploadFile(modelFile.path, documentId, 'application/x-tgsticker');
// Сохраняем DocumentId и AccessHash как объекты Long (формат MongoDB)
const documentReadModel = {
_id: `document-${documentId}`,
DocumentId: documentId,
AccessHash: BigInt(accessHash),
FileReference: fileReference,
DcId: 2,
Date: Math.floor(Date.now() / 1000),
MimeType: 'application/x-tgsticker',
Size: fileBuffer.length,
Name: modelFile.originalname,
Md5CheckSum: null,
CreatorId: null,
Thumbs: null,
ThumbId: null,
VideoThumbs: null,
VideoThumbId: null,
Version: 1,
Fingerprint: null,
Attributes: null,
Attributes2: [
{ _t: 'TDocumentAttributeFilename', FileName: modelFile.originalname },
{ _t: 'TDocumentAttributeImageSize', W: 512, H: 512 },
{ _t: 'TDocumentAttributeAnimated' },
{ _t: 'TDocumentAttributeSticker', Mask: false, Alt: '⭐️', Stickerset: { _t: 'TInputStickerSetEmpty' }, MaskCoords: null }
]
};
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
await fs.unlink(modelFile.path);
attributeSet.Model = { DocumentId: documentId, RarityPermille: parseInt(modelRarity) || 1000 };
console.log(`Model uploaded: DocumentId=${documentId}, AccessHash=${accessHash}`);
}
// Узор (PNG или TGS)
if (req.files.pattern && req.files.pattern[0]) {
const patternFile = req.files.pattern[0];
const documentId = generateDocumentId();
const accessHash = generateAccessHash();
const fileReference = generateFileReference();
const ext = path.extname(patternFile.originalname).toLowerCase();
let mimeType = 'image/png';
let attributes = [
{ _t: 'TDocumentAttributeFilename', FileName: patternFile.originalname },
{ _t: 'TDocumentAttributeImageSize', W: 512, H: 512 }
];
const fileBuffer = await fs.readFile(patternFile.path);
if (ext === '.tgs') {
mimeType = 'application/x-tgsticker';
try {
const decompressed = await gunzip(fileBuffer);
JSON.parse(decompressed.toString('utf8'));
} catch (error) {
try {
JSON.parse(fileBuffer.toString('utf8'));
} catch (jsonError) {
await fs.unlink(patternFile.path);
return res.status(400).json({ error: 'Invalid TGS file for pattern' });
}
}
attributes.push({ _t: 'TDocumentAttributeAnimated' });
attributes.push({ _t: 'TDocumentAttributeSticker', Alt: '⭐️', Stickerset: { _t: 'TInputStickerSetEmpty' }, Mask: false });
}
await minioHelper.uploadFile(patternFile.path, documentId, mimeType);
const documentReadModel = {
_id: `document-${documentId}`,
DocumentId: documentId,
AccessHash: BigInt(accessHash),
FileReference: fileReference,
DcId: 2,
Date: Math.floor(Date.now() / 1000),
MimeType: mimeType,
Size: fileBuffer.length,
Name: patternFile.originalname,
Md5CheckSum: null,
CreatorId: null,
Thumbs: null,
ThumbId: null,
VideoThumbs: null,
VideoThumbId: null,
Version: 1,
Fingerprint: null,
Attributes: null,
Attributes2: attributes
};
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
await fs.unlink(patternFile.path);
attributeSet.Pattern = { DocumentId: documentId, RarityPermille: parseInt(patternRarity) || 1000 };
}
// Фон генерируется на сервере автоматически из 100 цветовых схем,
// загружать файлы фона или задавать цвета вручную не требуется
await req.db.collection('StarGiftAttributeSets').insertOne(attributeSet);
res.status(201).json(toJSON(attributeSet));
} catch (error) {
console.error('Create attribute set error:', error);
if (req.files) {
const allFiles = [...(req.files.model || []), ...(req.files.pattern || [])];
for (const file of allFiles) {
await fs.unlink(file.path).catch(() => { });
}
}
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', async (req, res) => {
try {
const setId = req.params.id;
console.log(`[DELETE] Attempting to delete attribute set with ID: ${setId}`);
// Сначала пробуем найти по _id как по строке
let attributeSet = await req.db.collection('StarGiftAttributeSets').findOne({ _id: setId });
// Если не нашли, а ID похож на ObjectId, пробуем как ObjectId
if (!attributeSet && setId.match(/^[0-9a-fA-F]{24}$/)) {
console.log(`Trying with ObjectId format...`);
attributeSet = await req.db.collection('StarGiftAttributeSets').findOne({ _id: new ObjectId(setId) });
}
if (!attributeSet) {
console.log(`Attribute set not found with ID: ${setId}`);
return res.status(404).json({ error: 'Attribute set not found' });
}
console.log(`Found attribute set: ${attributeSet.Name}`);
const documentIds = [];
if (attributeSet.Model) documentIds.push(attributeSet.Model.DocumentId);
if (attributeSet.Pattern) documentIds.push(attributeSet.Pattern.DocumentId);
// Фон генерируется автоматически, удалять документ не нужно
for (const docId of documentIds) {
await req.db.collection('ReadModel-DocumentReadModel').deleteOne({ DocumentId: docId });
try {
await minioHelper.deleteFile(docId);
} catch (err) {
console.warn(`Failed to delete file ${docId} from MinIO:`, err);
}
}
// Удаляем в том же формате _id, в каком запись была найдена
await req.db.collection('StarGiftAttributeSets').deleteOne({ _id: attributeSet._id });
console.log(`Successfully deleted attribute set: ${attributeSet.Name}`);
res.json({ message: 'Attribute set deleted successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST /attributes/upload-zip - загрузка ZIP-архива с улучшениями подарка
router.post('/upload-zip', uploadZip.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const { giftId } = req.body;
if (!giftId) {
return res.status(400).json({ error: 'giftId is required' });
}
console.log(`Processing ZIP upload for gift ${giftId}: ${req.file.originalname}`);
const AdmZip = (await import('adm-zip')).default;
const zip = new AdmZip(req.file.path);
const zipEntries = zip.getEntries();
// Ищем info.json
const infoEntry = zipEntries.find(entry => entry.entryName === 'info.json' || entry.entryName.endsWith('/info.json'));
if (!infoEntry) {
return res.status(400).json({ error: 'info.json not found in ZIP archive' });
}
// Разбираем info.json
const infoContent = infoEntry.getData().toString('utf8');
const info = JSON.parse(infoContent);
const giftName = info.gift_name || `Gift ${giftId}`;
const models = info.models || [];
const patterns = info.patterns || [];
const backdrops = info.backdrops || [];
console.log(`Found info.json: gift="${giftName}", models=${models.length}, patterns=${patterns.length}, backdrops=${backdrops.length}`);
if (models.length === 0 || patterns.length === 0) {
return res.status(400).json({ error: 'No models or patterns found in info.json' });
}
const results = [];
const db = req.db;
// Формируем комбинации: каждая модель с каждым узором и случайным фоном
const maxCombinations = Math.min(models.length * patterns.length, 100); // не более 100 комбинаций
console.log(`Creating ${maxCombinations} attribute combinations...`);
for (let i = 0; i < Math.min(models.length, 20); i++) {
const model = models[i];
for (let j = 0; j < Math.min(patterns.length, 5); j++) {
if (results.length >= 50) break; // общее ограничение
const pattern = patterns[j];
const backdrop = backdrops[Math.floor(Math.random() * backdrops.length)];
console.log(`\nProcessing combination ${results.length + 1}: ${model.name} + ${pattern.name}`);
let modelDocId = null;
let patternDocId = null;
// Загружаем файл модели
if (model.file) {
const { entry: modelEntry, reason } = findZipEntryByName(zipEntries, model.file);
if (modelEntry) {
const modelBuffer = modelEntry.getData();
modelDocId = await uploadDocument(db, modelBuffer, model.file, 'model');
console.log(` Model uploaded: ${model.file} -> ${modelDocId} (${reason})`);
} else {
console.log(` Model file not found: ${model.file} (${reason})`);
continue; // пропускаем эту комбинацию
}
}
// Загружаем файл узора
if (pattern.file) {
const { entry: patternEntry, reason } = findZipEntryByName(zipEntries, pattern.file);
if (patternEntry) {
const patternBuffer = patternEntry.getData();
patternDocId = await uploadDocument(db, patternBuffer, pattern.file, 'pattern');
console.log(` Pattern uploaded: ${pattern.file} -> ${patternDocId} (${reason})`);
} else {
console.log(` Pattern file not found: ${pattern.file} (${reason})`);
continue; // пропускаем эту комбинацию
}
}
// Создаём запись набора атрибутов
const attributeSet = {
_id: Date.now().toString() + '-' + Math.random().toString(36).substring(7), // генерируем уникальный ID
GiftId: parseInt(giftId),
Name: `${model.name} ${pattern.name}`,
Model: modelDocId ? {
DocumentId: modelDocId,
RarityPermille: Math.round(model.rarity_percent * 10) // переводим проценты в промилле
} : null,
Pattern: patternDocId ? {
DocumentId: patternDocId,
RarityPermille: Math.round(pattern.rarity_percent * 10)
} : null,
Backdrop: backdrop ? {
BackdropId: backdrop.id || parseInt(giftId),
CenterColor: parseInt(backdrop.center_color?.replace('#', '') || 'FF0000', 16),
EdgeColor: parseInt(backdrop.edge_color?.replace('#', '') || '0000FF', 16),
PatternColor: parseInt(backdrop.pattern_color?.replace('#', '') || '5AC8FA', 16),
TextColor: parseInt(backdrop.text_color?.replace('#', '') || 'FFFFFF', 16),
RarityPermille: Math.round(backdrop.rarity_percent * 10)
} : null,
ModelName: model.name,
PatternName: pattern.name,
BackdropName: backdrop?.name || 'Random',
CreatedAt: new Date() // отметка времени для единообразия
};
// Добавляем запись в StarGiftAttributeSets
await db.collection('StarGiftAttributeSets').insertOne(attributeSet);
console.log(` Attribute set created: ${model.name} + ${pattern.name}`);
results.push({
model: model.name,
pattern: pattern.name,
backdrop: backdrop?.name,
modelDocId,
patternDocId
});
}
if (results.length >= 50) break;
}
// Удаляем загруженный ZIP-файл
await fs.unlink(req.file.path);
console.log(`\nSuccessfully processed ${results.length} upgrades for gift ${giftId}`);
res.json({
success: true,
giftId: parseInt(giftId),
title: giftName,
upgradesProcessed: results.length,
results
});
} catch (error) {
console.error('ZIP upload error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Вспомогательная функция: сохраняет документ в MongoDB
async function uploadDocument(db, fileBuffer, filename, type) {
const documentId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
const document = {
_id: `document-${documentId}`,
Id: `document-${documentId}`,
DocumentId: documentId,
AccessHash: generateAccessHash(),
FileReference: generateFileReference(),
Date: Math.floor(Date.now() / 1000),
MimeType: 'application/x-tgsticker',
Size: fileBuffer.length,
DcId: 2,
// Attributes2 оставляем пустым: сервер ждёт TL-типы, а не сырой JSON
Attributes2: [],
// Attributes не задаём массивом — опускаем или ставим null, сервер ждёт byte[].
// Здесь поле опущено, чтобы соответствовать схеме
Thumbs: [],
VideoThumbs: []
};
// Пишем в обе коллекции, чтобы query-server увидел документ
await db.collection('eventflow-documentreadmodel').insertOne(document);
await db.collection('ReadModel-DocumentReadModel').insertOne(document);
// Заливаем содержимое в MinIO под именем объекта = DocumentId (без расширения).
// Сохраняем буфер во временный файл, загружаем и удаляем его
const tmpDir = 'uploads/tmp';
await fs.mkdir(tmpDir, { recursive: true });
const tmpPath = `${tmpDir}/doc_${documentId}.bin`;
await fs.writeFile(tmpPath, fileBuffer);
try {
// отложенный импорт, чтобы избежать циклической зависимости
const { uploadFile } = (await import('../utils/minioHelper.js')).default ? (await import('../utils/minioHelper.js')).default : await import('../utils/minioHelper.js');
// minioHelper экспортирует default с методами; поддерживаем и default, и именованный экспорт
const helper = (await import('../utils/minioHelper.js')).default;
if (helper && helper.uploadFile) {
await helper.uploadFile(tmpPath, documentId, 'application/x-tgsticker');
} else if (uploadFile) {
await uploadFile(tmpPath, documentId, 'application/x-tgsticker');
}
} finally {
try { await fs.unlink(tmpPath); } catch {}
}
return documentId;
}
export default router;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,374 @@
import express from 'express';
import { MongoClient } from 'mongodb';
const router = express.Router();
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017';
const DB_NAME = 'tg';
// Get MongoDB client
async function getMongoClient() {
const client = new MongoClient(MONGO_URI);
await client.connect();
return client;
}
// Get all frozen accounts
router.get('/', async (req, res) => {
let client;
try {
client = await getMongoClient();
const db = client.db(DB_NAME);
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
const frozenAccounts = await frozenCollection.find({}).sort({ CreatedDate: -1 }).toArray();
res.json({
success: true,
data: frozenAccounts,
count: frozenAccounts.length
});
} catch (error) {
console.error('Error fetching frozen accounts:', error);
res.status(500).json({ success: false, error: error.message });
} finally {
if (client) await client.close();
}
});
// Get frozen account by user ID
router.get('/user/:userId', async (req, res) => {
let client;
try {
const userId = parseInt(req.params.userId);
client = await getMongoClient();
const db = client.db(DB_NAME);
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
const frozenAccount = await frozenCollection.findOne({ UserId: userId });
if (!frozenAccount) {
return res.status(404).json({ success: false, error: 'Frozen account not found' });
}
res.json({
success: true,
data: frozenAccount
});
} catch (error) {
console.error('Error fetching frozen account:', error);
res.status(500).json({ success: false, error: error.message });
} finally {
if (client) await client.close();
}
});
// Freeze account
router.post('/freeze', async (req, res) => {
let client;
try {
const {
userId: userIdRaw,
reason,
durationDays = 7,
moderatorUserId,
note
} = req.body;
if (!userIdRaw) {
return res.status(400).json({ success: false, error: 'userId is required' });
}
// Преобразуем userId в число
const userId = parseInt(userIdRaw);
if (isNaN(userId)) {
return res.status(400).json({ success: false, error: 'userId must be a valid number' });
}
client = await getMongoClient();
const db = client.db(DB_NAME);
const usersCollection = db.collection('eventflow-userreadmodel');
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
// Check if user exists
let user = await usersCollection.findOne({ UserId: userId });
if (!user) {
// Try alternative collection
user = await db.collection('ReadModel-UserReadModel').findOne({ UserId: userId });
if (!user) {
return res.status(404).json({
success: false,
error: `User ${userId} not found. Make sure the user is registered in the system.`,
hint: 'The user must have logged in at least once to MyTelegram'
});
}
}
// Check if already frozen (any status)
const existingFrozen = await frozenCollection.findOne({ UserId: userId });
if (existingFrozen) {
// Если уже заморожен с Status: 1 (Active)
if (existingFrozen.Status === 1) {
return res.status(400).json({
success: false,
error: 'User is already frozen with active status'
});
}
// Если запись существует но не активна (Status: 2 или 3), обновляем её
const now = Math.floor(Date.now() / 1000);
const freezeUntil = now + (durationDays * 24 * 60 * 60);
await frozenCollection.updateOne(
{ UserId: userId },
{
$set: {
FreezeSinceDate: now,
FreezeUntilDate: freezeUntil,
Reason: reason || 4,
Status: 1, // Active
AppealUrl: 'https://t.me/spambot',
ModeratorUserId: moderatorUserId || null,
FreezeNote: note || null,
LastModifiedDate: new Date(),
Metadata: {
frozenBy: 'admin_panel',
timestamp: new Date().toISOString()
}
}
}
);
// Update user record
await usersCollection.updateOne(
{ UserId: userId },
{
$set: {
IsFrozen: true,
FreezeSinceDate: now,
FreezeUntilDate: freezeUntil,
FreezeReason: reason || 4,
FreezeAppealUrl: 'https://t.me/spambot'
}
}
);
return res.json({
success: true,
message: 'Account re-frozen successfully (updated existing record)',
data: { ...existingFrozen, Status: 1, FreezeSinceDate: now, FreezeUntilDate: freezeUntil }
});
}
// Новая заморозка - создаем запись
const now = Math.floor(Date.now() / 1000);
const freezeUntil = now + (durationDays * 24 * 60 * 60);
const frozenDoc = {
_id: `frozen-${userId}`,
UserId: userId,
FreezeSinceDate: now,
FreezeUntilDate: freezeUntil,
Reason: reason || 4, // Default: TosViolation
Status: 1, // Active
AppealUrl: 'https://t.me/spambot',
ModeratorUserId: moderatorUserId || null,
FreezeNote: note || null,
CreatedDate: new Date(),
Metadata: {
frozenBy: 'admin_panel',
timestamp: new Date().toISOString()
}
};
await frozenCollection.insertOne(frozenDoc);
// Update user record
await usersCollection.updateOne(
{ UserId: userId },
{
$set: {
IsFrozen: true,
FreezeSinceDate: now,
FreezeUntilDate: freezeUntil,
FreezeReason: reason || 4,
FreezeAppealUrl: 'https://t.me/spambot'
}
}
);
res.json({
success: true,
message: 'Account frozen successfully',
data: frozenDoc
});
} catch (error) {
console.error('Error freezing account:', error);
res.status(500).json({ success: false, error: error.message });
} finally {
if (client) await client.close();
}
});
// Unfreeze account
router.post('/unfreeze', async (req, res) => {
let client;
try {
const { userId: userIdRaw, moderatorUserId, note } = req.body;
if (!userIdRaw) {
return res.status(400).json({ success: false, error: 'userId is required' });
}
const userId = parseInt(userIdRaw);
client = await getMongoClient();
const db = client.db(DB_NAME);
const usersCollection = db.collection('eventflow-userreadmodel');
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
// Check if frozen
const frozenAccount = await frozenCollection.findOne({ UserId: userId });
if (!frozenAccount) {
return res.status(404).json({ success: false, error: 'Frozen account not found' });
}
// Update frozen record
await frozenCollection.updateOne(
{ UserId: userId },
{
$set: {
Status: 3, // Approved
LastModifiedDate: new Date()
}
}
);
// Update user record
await usersCollection.updateOne(
{ UserId: userId },
{
$set: {
IsFrozen: false
},
$unset: {
FreezeSinceDate: "",
FreezeUntilDate: "",
FreezeReason: "",
FreezeAppealUrl: ""
}
}
);
res.json({
success: true,
message: 'Account unfrozen successfully'
});
} catch (error) {
console.error('Error unfreezing account:', error);
res.status(500).json({ success: false, error: error.message });
} finally {
if (client) await client.close();
}
});
// Get appeals
router.get('/appeals', async (req, res) => {
let client;
try {
client = await getMongoClient();
const db = client.db(DB_NAME);
const appealsCollection = db.collection('ReadModel-UserFrozenAppealHistoryReadModel');
const appeals = await appealsCollection.find({}).sort({ SubmittedDate: -1 }).toArray();
res.json({
success: true,
data: appeals,
count: appeals.length
});
} catch (error) {
console.error('Error fetching appeals:', error);
res.status(500).json({ success: false, error: error.message });
} finally {
if (client) await client.close();
}
});
// Review appeal
router.post('/appeals/:appealId/review', async (req, res) => {
let client;
try {
const { appealId } = req.params;
const { status, moderatorUserId, reviewNote } = req.body;
if (!status || ![2, 3].includes(status)) {
return res.status(400).json({ success: false, error: 'Invalid status (2=Approved, 3=Rejected)' });
}
client = await getMongoClient();
const db = client.db(DB_NAME);
const appealsCollection = db.collection('ReadModel-UserFrozenAppealHistoryReadModel');
const appeal = await appealsCollection.findOne({ AppealId: appealId });
if (!appeal) {
return res.status(404).json({ success: false, error: 'Appeal not found' });
}
// Update appeal
await appealsCollection.updateOne(
{ AppealId: appealId },
{
$set: {
Status: status,
ReviewerModeratorId: moderatorUserId,
ReviewNote: reviewNote,
ReviewedDate: new Date()
}
}
);
// If approved, unfreeze the account
if (status === 2) {
const usersCollection = db.collection('eventflow-userreadmodel');
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
await frozenCollection.updateOne(
{ UserId: appeal.UserId },
{ $set: { Status: 3 } } // Approved
);
await usersCollection.updateOne(
{ UserId: appeal.UserId },
{
$set: { IsFrozen: false },
$unset: {
FreezeSinceDate: "",
FreezeUntilDate: "",
FreezeReason: "",
FreezeAppealUrl: ""
}
}
);
}
res.json({
success: true,
message: status === 2 ? 'Appeal approved' : 'Appeal rejected'
});
} catch (error) {
console.error('Error reviewing appeal:', error);
res.status(500).json({ success: false, error: error.message });
} finally {
if (client) await client.close();
}
});
export default router;
@@ -0,0 +1,172 @@
import express from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { MongoClient } from 'mongodb';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = express.Router();
// Configure multer for TGS file upload
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadDir = path.join(__dirname, '../../uploads/frozen');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function (req, file, cb) {
cb(null, 'snowflake-' + Date.now() + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
fileFilter: function (req, file, cb) {
// Accept TGS files
if (file.mimetype === 'application/x-tgsticker' ||
file.originalname.endsWith('.tgs') ||
file.mimetype === 'application/gzip') {
cb(null, true);
} else {
cb(new Error('Only TGS files are allowed!'), false);
}
},
limits: {
fileSize: 64 * 1024 // 64KB max for TGS
}
});
/**
* GET /api/frozen-settings
* Get current frozen account settings
*/
router.get('/', async (req, res) => {
try {
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
await client.connect();
const db = client.db('tg');
const settingsCollection = db.collection('settings');
// Get frozen settings
let settings = await settingsCollection.findOne({ _id: 'frozen-account-settings' });
if (!settings) {
settings = {
_id: 'frozen-account-settings',
snowflakeEmojiId: null,
snowflakeFileName: null,
snowflakeUploadedAt: null
};
}
await client.close();
res.json({
success: true,
settings: settings
});
} catch (error) {
console.error('Error fetching frozen settings:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/frozen-settings/upload-snowflake
* Upload TGS snowflake sticker
*/
router.post('/upload-snowflake', upload.single('snowflake'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: 'No file uploaded' });
}
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
await client.connect();
const db = client.db('tg');
const settingsCollection = db.collection('settings');
// Save file info to settings
const settings = {
_id: 'frozen-account-settings',
snowflakeFileName: req.file.filename,
snowflakeFilePath: req.file.path,
snowflakeUploadedAt: new Date(),
snowflakeEmojiId: null // Will be set after uploading to MyTelegram
};
await settingsCollection.replaceOne(
{ _id: 'frozen-account-settings' },
settings,
{ upsert: true }
);
await client.close();
res.json({
success: true,
message: 'Snowflake TGS uploaded successfully',
file: {
filename: req.file.filename,
size: req.file.size,
path: req.file.path
}
});
} catch (error) {
console.error('Error uploading snowflake:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/frozen-settings/set-emoji-id
* Set the Document ID for frozen snowflake emoji
*/
router.post('/set-emoji-id', async (req, res) => {
try {
const { documentId } = req.body;
if (!documentId || isNaN(documentId)) {
return res.status(400).json({ success: false, error: 'Valid documentId is required' });
}
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
await client.connect();
const db = client.db('tg');
const settingsCollection = db.collection('settings');
// Update emoji ID
await settingsCollection.updateOne(
{ _id: 'frozen-account-settings' },
{
$set: {
snowflakeEmojiId: parseInt(documentId),
emojiIdUpdatedAt: new Date()
}
},
{ upsert: true }
);
await client.close();
res.json({
success: true,
message: 'Frozen snowflake emoji ID updated',
documentId: parseInt(documentId)
});
} catch (error) {
console.error('Error setting emoji ID:', error);
res.status(500).json({ success: false, error: error.message });
}
});
export default router;
+350
View File
@@ -0,0 +1,350 @@
import express from 'express';
import multer from 'multer';
import { promises as fs } from 'fs';
import path from 'path';
import Joi from 'joi';
import zlib from 'zlib';
import { promisify } from 'util';
import minioHelper from '../utils/minioHelper.js';
const gunzip = promisify(zlib.gunzip);
const router = express.Router();
// --- Configuration ---
const UPLOAD_DIR = 'uploads/stickers';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_MIME_TYPES = ['application/json', 'application/octet-stream', 'application/x-tgsticker'];
const ALLOWED_EXTENSIONS = ['.json', '.tgs'];
// --- Multer Setup ---
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
await fs.mkdir(UPLOAD_DIR, { recursive: true });
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`;
cb(null, `sticker-${uniqueSuffix}${path.extname(file.originalname)}`);
}
});
const upload = multer({
storage,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ALLOWED_EXTENSIONS.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Only .json and .tgs files are allowed'));
}
}
});
// --- Helpers ---
const toJSON = (data) => {
return JSON.parse(JSON.stringify(data, (key, value) => {
if (typeof value === 'bigint') return value.toString();
if (value && typeof value === 'object' && (value._bsontype === 'Long' || (value.low !== undefined && value.high !== undefined))) {
return value.toString();
}
return value;
}));
};
const generateId = () => Date.now() * 10000 + Math.floor(Math.random() * 10000);
const generateAccessHash = () => Date.now() * 100000 + Math.floor(Math.random() * 100000);
// --- Validation ---
const giftSchema = Joi.object({
giftId: Joi.alt([Joi.number().integer().positive(), Joi.string().pattern(/^\d+$/)]).required(),
stars: Joi.number().integer().positive().required(),
convertStars: Joi.number().integer().positive()
.less(Joi.ref('stars'))
.messages({
'number.less': 'convertStars must be less than stars'
})
.required(),
limited: Joi.boolean().default(false),
soldOut: Joi.boolean().default(false),
birthday: Joi.boolean().default(false),
requirePremium: Joi.boolean().default(false),
limitedPerUser: Joi.boolean().default(false),
availabilityTotal: Joi.number().integer().positive().optional(),
availabilityRemains: Joi.number().integer().min(0).max(Joi.ref('availabilityTotal')).optional(),
perUserTotal: Joi.when('limitedPerUser', {
is: true,
then: Joi.number().integer().positive().required(),
otherwise: Joi.forbidden()
}),
upgradeStars: Joi.number().integer().positive().optional().allow(null),
title: Joi.string().max(100).optional().allow(null, ''),
resellMinStars: Joi.number().integer().positive().optional().allow(null),
stickerId: Joi.alt([Joi.number().integer().positive(), Joi.string().pattern(/^\d+$/)]).optional(),
releasedBy: Joi.object({
type: Joi.string().valid('user', 'channel').required(),
id: Joi.number().integer().positive().required()
}).optional().allow(null),
// Эти поля не должны устанавливаться вручную
firstSaleDate: Joi.forbidden(),
lastSaleDate: Joi.forbidden()
});
// --- Routes ---
// GET All Gifts
router.get('/', async (req, res) => {
try {
const { soldOut, limited, sort = 'stars' } = req.query;
const filter = {};
if (soldOut !== undefined) filter.SoldOut = soldOut === 'true';
if (limited !== undefined) filter.Limited = limited === 'true';
const sortOptions = {};
if (sort === 'stars') sortOptions.Stars = 1;
else if (sort === 'date') sortOptions._id = -1;
else if (sort === 'availability') sortOptions.AvailabilityRemains = 1;
const gifts = await req.db.collection('AvailableStarGiftReadModel')
.find(filter).sort(sortOptions).toArray();
res.json(toJSON(gifts));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET Gift by ID
router.get('/:giftId', async (req, res) => {
try {
const giftId = BigInt(req.params.giftId);
const gift = await req.db.collection('AvailableStarGiftReadModel').findOne({ GiftId: giftId });
if (!gift) return res.status(404).json({ error: 'Gift not found' });
res.json(toJSON(gift));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST Create Gift
router.post('/', upload.single('sticker'), async (req, res) => {
let uploadedFilePath = null;
try {
const giftData = typeof req.body.data === 'string' ? JSON.parse(req.body.data) : req.body;
const { error, value } = giftSchema.validate(giftData);
if (error) return res.status(400).json({ error: error.details[0].message });
const safeGiftId = BigInt(value.giftId);
const existing = await req.db.collection('AvailableStarGiftReadModel').findOne({ GiftId: safeGiftId });
if (existing) return res.status(409).json({ error: 'Gift with this ID already exists' });
let stickerDocumentId = null;
if (req.file) {
uploadedFilePath = req.file.path;
const fileBuffer = await fs.readFile(uploadedFilePath);
// Проверяем TGS
try {
const decompressed = await gunzip(fileBuffer);
JSON.parse(decompressed.toString('utf8'));
} catch (e) {
try {
JSON.parse(fileBuffer.toString('utf8'));
} catch (jsonErr) {
throw new Error('Invalid TGS file');
}
}
const documentId = generateId();
const accessHash = generateAccessHash();
await minioHelper.uploadFile(uploadedFilePath, documentId, 'application/x-tgsticker');
// Правильный FileReference на 24 байта вместо 8
const fileRefBuffer = Buffer.allocUnsafe(24);
fileRefBuffer.writeUInt8(2, 0); // версия 2
fileRefBuffer.writeUInt8(2, 1); // ID дата-центра
fileRefBuffer.writeBigInt64BE(BigInt(documentId), 2); // ID документа (8 байт)
fileRefBuffer.writeBigInt64BE(BigInt(accessHash), 10); // access hash (8 байт)
fileRefBuffer.writeUInt32BE(Math.floor(Date.now() / 1000), 18); // метка времени (4 байта)
fileRefBuffer.writeUInt16BE(Math.floor(Math.random() * 0xFFFF), 22); // случайные данные (2 байта)
const documentReadModel = {
_id: `document-${documentId}`,
Id: `document-${documentId}`,
DocumentId: documentId,
Version: 1,
AccessHash: accessHash,
FileReference: fileRefBuffer,
DcId: 2,
Date: Math.floor(Date.now() / 1000),
MimeType: 'application/x-tgsticker',
Size: fileBuffer.length,
Name: `${documentId}`,
Md5CheckSum: null,
CreatorId: null,
Thumbs: null, // null вместо хардкода — Telegram сгенерирует сам
ThumbId: null,
VideoThumbs: null,
VideoThumbId: null,
Attributes: null,
Attributes2: [
{ _t: 'TDocumentAttributeImageSize', W: 512, H: 512 },
{
_t: 'TDocumentAttributeSticker',
Alt: '🎁',
Stickerset: {
_t: 'TInputStickerSetEmpty'
},
Mask: false
},
{ _t: 'TDocumentAttributeAnimated' },
{ _t: 'TDocumentAttributeFilename', FileName: 'sticker.tgs' }
]
};
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
stickerDocumentId = documentId;
await fs.unlink(uploadedFilePath);
uploadedFilePath = null;
} else if (value.stickerId) {
stickerDocumentId = BigInt(value.stickerId);
}
const gift = {
_id: value.giftId.toString(),
GiftId: safeGiftId,
Limited: value.limited,
SoldOut: value.soldOut,
Birthday: value.birthday,
RequirePremium: value.requirePremium || false, // берём из value
LimitedPerUser: value.limitedPerUser || false, // берём из value
Stars: value.stars,
ConvertStars: value.convertStars,
UpgradeStars: value.upgradeStars || null,
AvailabilityTotal: value.availabilityTotal || null,
AvailabilityRemains: value.availabilityRemains ?? value.availabilityTotal ?? null,
AvailabilityResale: null,
FirstSaleDate: null, // null — установится при первой покупке
LastSaleDate: null, // null — установится при покупке
Title: value.title || null, // null вместо пустой строки
ResellMinStars: value.resellMinStars || null,
Sticker: stickerDocumentId,
ReleasedBy: value.releasedBy ? { // поддержка ReleasedBy
Type: value.releasedBy.type,
Id: value.releasedBy.id
} : null,
PerUserTotal: value.limitedPerUser ? value.perUserTotal : null, // берём из value
PerUserRemains: value.limitedPerUser ? value.perUserTotal : null, // берём из value
LockedUntilDate: null,
Version: 1
};
await req.db.collection('AvailableStarGiftReadModel').insertOne(gift);
res.status(201).json(toJSON(gift));
} catch (error) {
console.error('Create gift error:', error);
if (uploadedFilePath) await fs.unlink(uploadedFilePath).catch(() => { });
res.status(500).json({ error: error.message });
}
});
// PUT Update Gift
router.put('/:giftId', async (req, res) => {
try {
const giftId = BigInt(req.params.giftId);
const giftData = req.body;
delete giftData.giftId;
const result = await req.db.collection('AvailableStarGiftReadModel').findOneAndUpdate(
{ GiftId: giftId },
{ $set: { ...giftData, UpdatedAt: new Date() } },
{ returnDocument: 'after' }
);
if (!result) return res.status(404).json({ error: 'Gift not found' });
res.json(toJSON(result));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE Gift
router.delete('/:giftId', async (req, res) => {
try {
const giftId = BigInt(req.params.giftId);
const result = await req.db.collection('AvailableStarGiftReadModel').deleteOne({ GiftId: giftId });
if (result.deletedCount === 0) return res.status(404).json({ error: 'Gift not found' });
res.json({ message: 'Gift deleted' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST Send Gift to User (Admin/Test)
router.post('/:giftId/send-to-user', async (req, res) => {
try {
const giftId = BigInt(req.params.giftId);
const { userId, fromUserId, message, nameHidden, count } = req.body;
if (!userId) return res.status(400).json({ error: 'userId is required' });
const gift = await req.db.collection('AvailableStarGiftReadModel').findOne({ GiftId: giftId });
if (!gift) return res.status(404).json({ error: 'Gift not found' });
const fromId = fromUserId ? Number(fromUserId) : 0;
if (!fromId || fromId <= 0) return res.status(400).json({ success: false, error: 'fromUserId is required (stars will be spent)' });
const payload = {
fromUserId: fromId,
toUserId: parseInt(userId),
giftId: Number(giftId),
count: count || 1,
nameHidden: !!nameHidden,
canUpgrade: null,
message: message || null
};
const adminUrl = process.env.ADMIN_API_URL || 'http://localhost:5000';
const adminKey = process.env.ADMIN_API_KEY || '';
const response = await fetch(`${adminUrl}/api/admin/star-gifts/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-API-Key': adminKey
},
body: JSON.stringify(payload)
});
const rawText = await response.text();
let data = {};
try {
data = rawText ? JSON.parse(rawText) : {};
} catch {
data = { error: rawText };
}
if (!response.ok) {
console.error('Send gift error:', { status: response.status, body: data });
return res.status(response.status).json(data);
}
res.status(200).json({ success: true, ...data });
} catch (error) {
console.error('Send gift error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;
@@ -0,0 +1,588 @@
import express from 'express';
import multer from 'multer';
import { promises as fs } from 'fs';
import path from 'path';
import Joi from 'joi';
import zlib from 'zlib';
import { promisify } from 'util';
import { Long } from 'mongodb';
import minioHelper from '../utils/minioHelper.js';
const gunzip = promisify(zlib.gunzip);
const router = express.Router();
// File upload configuration
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = 'uploads/stickers';
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, `sticker-${uniqueSuffix}${path.extname(file.originalname)}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
const allowedTypes = ['application/json', 'application/octet-stream', 'application/x-tgsticker'];
const allowedExts = ['.json', '.tgs'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Only .json and .tgs files are allowed'));
}
}
});
// Helper to handle BigInt and BSON Long serialization
const toJSON = (data) => {
return JSON.parse(JSON.stringify(data, (key, value) => {
// Handle native BigInt
if (typeof value === 'bigint') {
return value.toString();
}
// Handle BSON Long objects (which have low, high, unsigned properties)
if (value && typeof value === 'object' && (value._bsontype === 'Long' || (value.hasOwnProperty('low') && value.hasOwnProperty('high')))) {
return value.toString();
}
return value;
}));
};
// Validation schemas
const giftSchema = Joi.object({
// ✅ FIX: Allow string input for large 64-bit IDs
giftId: Joi.alt([
Joi.number().integer().positive(),
Joi.string().pattern(/^\d+$/)
]).required(),
limited: Joi.boolean().default(false),
soldOut: Joi.boolean().default(false),
birthday: Joi.boolean().default(false),
stars: Joi.number().integer().positive().required(),
convertStars: Joi.number().integer().positive().required(),
upgradeStars: Joi.number().integer().positive().optional(),
availabilityTotal: Joi.number().integer().positive().optional().allow(null),
availabilityRemains: Joi.number().integer().min(0).optional().allow(null),
firstSaleDate: Joi.number().integer().optional().allow(null),
lastSaleDate: Joi.number().integer().optional().allow(null),
title: Joi.string().max(100).optional().allow(null, ''),
resellMinStars: Joi.number().integer().positive().optional().allow(null)
});
// GET all gifts
router.get('/', async (req, res) => {
try {
const { soldOut, limited, sort = 'stars' } = req.query;
const filter = {};
if (soldOut !== undefined) filter.SoldOut = soldOut === 'true';
if (limited !== undefined) filter.Limited = limited === 'true';
const sortOptions = {};
if (sort === 'stars') sortOptions.Stars = 1;
else if (sort === 'date') sortOptions._id = -1;
else if (sort === 'availability') sortOptions.AvailabilityRemains = 1;
const gifts = await req.db
.collection('AvailableStarGiftReadModel')
.find(filter)
.sort(sortOptions)
.toArray();
res.json(toJSON(gifts));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET gift by ID
router.get('/:giftId', async (req, res) => {
try {
// ✅ FIX: Use BigInt for large IDs
const giftId = BigInt(req.params.giftId);
const gift = await req.db
.collection('AvailableStarGiftReadModel')
.findOne({ GiftId: giftId });
if (!gift) {
return res.status(404).json({ error: 'Gift not found' });
}
res.json(toJSON(gift));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST create new gift
router.post('/', upload.single('sticker'), async (req, res) => {
try {
// Parse JSON data from form
const giftData = typeof req.body.data === 'string'
? JSON.parse(req.body.data)
: req.body;
// Validate
const { error, value } = giftSchema.validate(giftData);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
// ✅ FIX: Convert to BigInt for DB check
const safeGiftId = BigInt(value.giftId);
// Check if gift ID already exists
const existing = await req.db
.collection('AvailableStarGiftReadModel')
.findOne({ GiftId: safeGiftId });
if (existing) {
return res.status(409).json({ error: 'Gift with this ID already exists' });
}
// ✅ Upload sticker file to MinIO and create DocumentReadModel
let stickerDocumentId = null;
if (req.file) {
try {
console.log(`📤 Uploading sticker: ${req.file.originalname}, Size: ${req.file.size} bytes`);
// Validate TGS file
const fileBuffer = await fs.readFile(req.file.path);
// Try to decompress, if fails assume it's already JSON
let json;
try {
const decompressed = await gunzip(fileBuffer);
json = JSON.parse(decompressed.toString('utf8'));
} catch (error) {
// If gunzip fails, try parsing as plain JSON
try {
json = JSON.parse(fileBuffer.toString('utf8'));
console.log('⚠️ File is not gzipped, treating as plain JSON');
} catch (jsonError) {
throw new Error('Invalid TGS file: not gzipped JSON or plain JSON');
}
}
// Generate document ID for sticker
const documentId = Date.now() * 10000 + Math.floor(Math.random() * 10000);
const accessHash = Date.now() * 100000 + Math.floor(Math.random() * 100000);
// Upload to MinIO (same format as emoji - root path, no extension)
await minioHelper.uploadFile(req.file.path, documentId, 'application/x-tgsticker');
console.log(`✅ Uploaded sticker to MinIO: ${documentId}`);
// Generate FileReference (8 bytes)
const fileRefBuffer = Buffer.allocUnsafe(8);
fileRefBuffer.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
fileRefBuffer.writeUInt32BE(Math.floor(Math.random() * 0xFFFFFFFF), 4);
// Create DocumentReadModel (same as emoji)
const documentReadModel = {
_id: `document-${documentId}`, // EventFlow ID format
Id: `document-${documentId}`, // EventFlow requires this
DocumentId: documentId,
Version: 1,
AccessHash: accessHash,
FileReference: fileRefBuffer,
DcId: 2,
Date: Math.floor(Date.now() / 1000),
MimeType: 'application/x-tgsticker',
Size: fileBuffer.length,
Name: `${documentId}`,
Md5CheckSum: null,
CreatorId: null,
Thumbs: [
{
Type: 'i',
// Minimal valid stripped thumbnail (blue-ish placeholder)
Bytes: Buffer.from([0x01, 0x08, 0x08, 0x2a, 0x8c, 0x47, 0x92, 0x48, 0x20])
}
],
ThumbId: null,
VideoThumbs: null,
VideoThumbId: null,
Attributes: null,
Attributes2: [
{
_t: 'TDocumentAttributeImageSize',
W: 512,
H: 512
},
{
_t: 'TDocumentAttributeAnimated' // ✅ CRITICAL for TGS stickers!
},
{
_t: 'TDocumentAttributeSticker',
Alt: '🎁',
Stickerset: {
_t: 'TInputStickerSetShortName',
ShortName: 'premium_gifts'
},
Mask: false
},
{
_t: 'TDocumentAttributeFilename',
FileName: 'sticker.tgs'
}
]
};
// NOTE: Use EventFlow naming convention for ReadModel collections
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
console.log(`✅ Created DocumentReadModel for sticker: ${documentId}`);
stickerDocumentId = documentId;
// Cleanup temp file
// ✅ FIX: Use BigInt
const giftId = BigInt(req.params.giftId);
// Parse JSON data
const giftData = typeof req.body.data === 'string'
? JSON.parse(req.body.data)
: req.body;
const updateData = { ...giftData };
delete updateData.giftId; // Don't allow changing ID
// Read new sticker if uploaded - store as Buffer not base64
if (req.file) {
const fileBuffer = await fs.readFile(req.file.path);
updateData.Sticker = fileBuffer; // Store as Binary (Buffer)
}
const result = await req.db
.collection('AvailableStarGiftReadModel')
.findOneAndUpdate(
{ GiftId: giftId },
{
$set: {
...updateData,
UpdatedAt: new Date()
}
},
{ returnDocument: 'after' }
);
if (!result) {
return res.status(404).json({ error: 'Gift not found' });
}
res.json(toJSON(result));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE gift
router.delete('/:giftId', async (req, res) => {
try {
// ✅ FIX: Use BigInt
const giftId = BigInt(req.params.giftId);
const result = await req.db
.collection('AvailableStarGiftReadModel')
.deleteOne({ GiftId: giftId });
if (result.deletedCount === 0) {
return res.status(404).json({ error: 'Gift not found' });
}
res.json({ message: 'Gift deleted successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PATCH mark as sold out
router.patch('/:giftId/soldout', async (req, res) => {
try {
// ✅ FIX: Use BigInt
const giftId = BigInt(req.params.giftId);
const { soldOut } = req.body;
const result = await req.db
.collection('AvailableStarGiftReadModel')
.findOneAndUpdate(
{ GiftId: giftId },
{
$set: {
SoldOut: soldOut,
LastSaleDate: soldOut ? Math.floor(Date.now() / 1000) : null,
UpdatedAt: new Date()
}
},
{ returnDocument: 'after' }
);
if (!result) {
return res.status(404).json({ error: 'Gift not found' });
}
res.json(toJSON(result));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// DELETE all gifts (clear collection)
router.delete('/', async (req, res) => {
try {
const result = await req.db
.collection('AvailableStarGiftReadModel')
.deleteMany({});
res.json({
message: 'All gifts deleted successfully',
deletedCount: result.deletedCount
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// PATCH decrease availability
router.patch('/:giftId/purchase', async (req, res) => {
try {
// ✅ FIX: Use BigInt
const giftId = BigInt(req.params.giftId);
const gift = await req.db
.collection('AvailableStarGiftReadModel')
.findOne({ GiftId: giftId });
if (!gift) {
return res.status(404).json({ error: 'Gift not found' });
}
if (!gift.Limited) {
return res.status(400).json({ error: 'Gift is not limited' });
}
if (gift.AvailabilityRemains <= 0) {
return res.status(400).json({ error: 'Gift sold out' });
}
const result = await req.db
.collection('AvailableStarGiftReadModel')
.findOneAndUpdate(
{ GiftId: giftId },
{
$inc: { AvailabilityRemains: -1 },
$set: {
UpdatedAt: new Date(),
SoldOut: gift.AvailabilityRemains - 1 <= 0
}
},
{ returnDocument: 'after' }
);
res.json(toJSON(result));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST send gift to user (admin function)
router.post('/:giftId/send-to-user', async (req, res) => {
try {
// ✅ FIX: Use BigInt
const giftId = BigInt(req.params.giftId);
const { userId, fromUserId, message, nameHidden } = req.body;
// Validate required fields
if (!userId) {
return res.status(400).json({ error: 'userId is required' });
}
// Get gift from catalog
const gift = await req.db
.collection('AvailableStarGiftReadModel')
.findOne({ GiftId: giftId });
if (!gift) {
return res.status(404).json({ error: 'Gift not found' });
}
// Generate unique messageId (small int32 compatible)
// Use random number within int32 range: 1 to 2147483647
const messageId = Math.floor(Math.random() * 2000000000) + 1000000;
const now = Math.floor(Date.now() / 1000);
// Create received gift record
const receivedGift = {
_id: `${userId}_${messageId}`,
Id: `${userId}_${messageId}`,
GiftId: giftId, // ✅ Store as BigInt
FromUserId: fromUserId || 0, // 0 = system/admin
ToUserId: parseInt(userId),
ToPeerId: null,
MessageId: messageId,
Stars: gift.Stars,
ConvertStars: gift.ConvertStars,
Message: message || null,
NameHidden: nameHidden || false,
Saved: true, // Auto-save admin gifts
Converted: false,
Upgraded: false,
Refunded: false,
CanUpgrade: gift.UpgradeStars ? true : false,
Pinned: false,
SavedId: null,
UpgradeStars: gift.UpgradeStars,
UpgradeMsgId: null,
Date: now,
ConvertDate: null,
UpgradeDate: null,
StickerDocumentId: gift.Sticker, // DocumentId for sticker loading
Title: gift.Title || '', // Gift title
Version: 1
};
// Insert into eventflow-stargiftreadmodel collection
await req.db
.collection('eventflow-stargiftreadmodel')
.insertOne(receivedGift);
// Create inbox message for the recipient with ALL required fields
const inboxMessage = {
_id: `message-${userId}_${messageId}`,
Id: `message-${userId}_${messageId}`,
OwnerPeerId: parseInt(userId),
ToPeerId: parseInt(userId),
ToPeerType: 1, // User
SenderUserId: fromUserId || 0,
SenderPeerId: fromUserId || 0,
SenderMessageId: 0,
MessageId: messageId,
Message: '',
Date: now,
Out: false, // Inbox message
Pinned: false,
Post: false,
Silent: false,
Pts: 0, // Will be set by server
Views: null,
Replies: 0,
EditDate: null,
EditHide: false,
PostAuthor: null,
GroupedId: null,
ReplyToMsgId: null,
TopMsgId: null,
FwdHeader: null,
Media: null,
Media2: null,
Entities: null,
Entities2: null,
ReplyMarkup: null,
ReplyMarkup2: null,
ReplyTo: null,
SendAs: null,
Reply: null,
MessageType: 1, // Text
SendMessageType: 1, // MessageService
MessageActionType: 19, // CustomAction
MessageActionData: JSON.stringify({
_t: 'TMessageActionStarGift',
NameHidden: nameHidden || false,
Saved: false,
Converted: false,
Upgraded: false,
Refunded: false,
CanUpgrade: gift.UpgradeStars ? true : false,
Gift: {
Id: giftId.toString(), // ✅ Serialize BigInt to string for JSON
Stars: gift.Stars,
Title: gift.Title
},
Message: message ? {
Text: message,
Entities: []
} : null,
ConvertStars: gift.ConvertStars,
UpgradeStars: gift.UpgradeStars
}),
LinkedChannelId: null,
SavedFromMsgId: null,
SavedFromPeerId: null,
SavedPeerId: null,
PollId: null,
PostChannelId: null,
PostMessageId: null,
IsQuickReplyMessage: false,
ShortcutId: null,
QuickReplyItem: null,
BatchId: null,
Effect: null,
FromScheduled: false,
ScheduleDate: null,
TtlPeriod: null,
Reactions: null,
RecentReactions2: null,
RecentReactions: null,
TopReactors: null,
CanSeeList: false,
ExpirationTime: null,
InvertMedia: false,
PublicPosts: false,
Hashtags: [],
MentionedUserIds: null,
TodoId: null,
Version: 1
};
// Insert inbox message into ReadModel-MessageReadModel
await req.db
.collection('ReadModel-MessageReadModel')
.insertOne(inboxMessage);
// Decrease availability if limited
if (gift.Limited && gift.AvailabilityRemains > 0) {
await req.db
.collection('AvailableStarGiftReadModel')
.updateOne(
{ GiftId: giftId },
{
$inc: { AvailabilityRemains: -1 },
$set: {
UpdatedAt: new Date(),
SoldOut: gift.AvailabilityRemains - 1 <= 0
}
}
);
}
res.status(201).json({
success: true,
message: `Gift ${giftId} sent to user ${userId}`,
warning: 'Gift inserted into database. Restart Query Server to see it in user profile.',
receivedGift: {
userId: parseInt(userId),
giftId: giftId.toString(), // ✅ Serialize BigInt
messageId: messageId,
stars: gift.Stars,
title: gift.Title
},
instructions: {
step1: 'Gift has been added to database',
step2: 'To make it visible in user profile, restart Query Server:',
command: 'docker restart messenger-query-server-1'
}
});
} catch (error) {
console.error('Send gift error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;
@@ -0,0 +1,391 @@
import express from 'express';
import multer from 'multer';
import { MongoClient, ObjectId } from 'mongodb';
import AdmZip from 'adm-zip';
import pako from 'pako';
import * as Minio from 'minio';
const router = express.Router();
// Клиент MinIO
const minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || 'test',
secretKey: process.env.MINIO_SECRET_KEY || '12345678'
});
const BUCKET_NAME = process.env.MINIO_BUCKET || 'tg-files';
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 100 * 1024 * 1024 } // 100MB
});
const MONGO_URL = process.env.MONGODB_URI || 'mongodb://localhost:27017';
const DB_NAME = process.env.DB_NAME || 'tg';
// Список всех реакций
router.get('/', async (req, res) => {
const client = new MongoClient(MONGO_URL);
try {
await client.connect();
const db = client.db(DB_NAME);
const collection = db.collection('reactions');
const reactions = await collection.find({}).toArray();
res.json({
success: true,
reactions: reactions.map(r => ({
id: r._id.toString(),
emoji: r.emoji,
title: r.title,
premium: r.premium || false,
inactive: r.inactive || false,
hasAllAnimations: !!(r.staticIcon && r.appearAnimation && r.selectAnimation && r.activateAnimation && r.effectAnimation),
selectAnimation: r.selectAnimationPreview
}))
});
} catch (error) {
console.error('Error fetching reactions:', error);
res.status(500).json({ error: 'Failed to fetch reactions' });
} finally {
await client.close();
}
});
// Создать реакцию
router.post('/', upload.fields([
{ name: 'staticIcon', maxCount: 1 },
{ name: 'appearAnimation', maxCount: 1 },
{ name: 'selectAnimation', maxCount: 1 },
{ name: 'activateAnimation', maxCount: 1 },
{ name: 'effectAnimation', maxCount: 1 },
{ name: 'aroundAnimation', maxCount: 1 },
{ name: 'centerIcon', maxCount: 1 }
]), async (req, res) => {
const client = new MongoClient(MONGO_URL);
try {
const data = JSON.parse(req.body.data);
await client.connect();
const db = client.db(DB_NAME);
// Загружаем анимации в MinIO и создаём документы
const animations = {};
const animationFields = [
'staticIcon', 'appearAnimation', 'selectAnimation',
'activateAnimation', 'effectAnimation', 'aroundAnimation', 'centerIcon'
];
for (const field of animationFields) {
if (req.files[field] && req.files[field][0]) {
const file = req.files[field][0];
const documentId = Date.now() + Math.floor(Math.random() * 1000000);
// Загружаем в MinIO (file-server ожидает файлы в корне: {documentId})
await minioClient.putObject(BUCKET_NAME, `${documentId}`, file.buffer, file.size, {
'Content-Type': 'application/x-tgsticker'
});
// Создаём документ в MongoDB
const fileReference = Buffer.from([
0x01, 0x00, 0x00, 0x00,
(documentId >> 24) & 0xFF,
(documentId >> 16) & 0xFF,
(documentId >> 8) & 0xFF,
documentId & 0xFF
]);
const documentReadModelId = `document-${documentId}`;
const document = {
_id: documentReadModelId,
Id: documentReadModelId,
Version: 1,
DocumentId: documentId,
AccessHash: documentId,
FileReference: fileReference,
Date: Math.floor(Date.now() / 1000),
MimeType: 'application/x-tgsticker',
Size: file.size,
Name: `${documentId}`,
DcId: 2,
Md5CheckSum: null,
CreatorId: null,
Thumbs: null,
ThumbId: null,
VideoThumbs: null,
VideoThumbId: null,
Attributes: null,
Attributes2: [{
_t: 'TDocumentAttributeSticker',
Alt: data.emoji || '😀',
Stickerset: {
_t: 'TInputStickerSetEmpty'
}
}]
};
await db.collection('ReadModel-DocumentReadModel').insertOne(document);
animations[field] = documentId;
// Сохраняем превью для анимации выбора
if (field === 'selectAnimation') {
try {
const decompressed = pako.inflate(file.buffer, { to: 'string' });
animations.selectAnimationPreview = JSON.parse(decompressed);
} catch (e) {
console.log('Could not create preview:', e.message);
}
}
}
}
// Создаём реакцию
const reaction = {
emoji: data.emoji,
title: data.title,
premium: data.premium || false,
inactive: data.inactive || false,
...animations,
createdAt: new Date()
};
const result = await db.collection('reactions').insertOne(reaction);
res.json({
success: true,
reactionId: result.insertedId.toString()
});
} catch (error) {
console.error('Error creating reaction:', error);
res.status(500).json({ error: 'Failed to create reaction' });
} finally {
await client.close();
}
});
// Массовая загрузка из ZIP (по папкам: emoji/asset.ext)
router.post('/bulk-upload', upload.single('zipFile'), async (req, res) => {
const client = new MongoClient(MONGO_URL);
try {
if (!req.file) {
return res.status(400).json({ error: 'No ZIP file provided' });
}
const premium = req.body.premium === 'true';
const zip = new AdmZip(req.file.buffer);
const zipEntries = zip.getEntries();
await client.connect();
const db = client.db(DB_NAME);
const results = [];
const reactionsMap = new Map(); // Map<emoji, { assets: {}, title: string }>
// 1. Группируем файлы по эмодзи (имени папки)
for (const entry of zipEntries) {
if (entry.isDirectory || entry.entryName.startsWith('__MACOSX')) continue;
// Приводим разделители пути к единому виду
const entryPath = entry.entryName.replace(/\\/g, '/');
const parts = entryPath.split('/');
// Ожидаем как минимум папку и файл: folder/file.ext
if (parts.length < 2) continue;
// Папка с эмодзи — это непосредственный родитель файла
const emojiFolder = parts[parts.length - 2];
const filename = parts[parts.length - 1];
const nameWithoutExt = filename.split('.')[0];
// Проверяем тип ассета
const validAssets = [
'static_icon', 'appear_animation', 'select_animation',
'activate_animation', 'effect_animation', 'around_animation', 'center_icon'
];
if (!validAssets.includes(nameWithoutExt)) continue;
if (!reactionsMap.has(emojiFolder)) {
reactionsMap.set(emojiFolder, {
emoji: emojiFolder,
assets: {}
});
}
const reactionData = reactionsMap.get(emojiFolder);
reactionData.assets[nameWithoutExt] = {
buffer: entry.getData(),
filename: filename
};
}
// 2. Обрабатываем каждую реакцию
for (const [emoji, data] of reactionsMap) {
try {
console.log(`Processing reaction: ${emoji}`);
const animationIds = {};
// Загружаем ассеты
for (const [assetType, fileData] of Object.entries(data.assets)) {
const documentId = Date.now() + Math.floor(Math.random() * 1000000);
// Определяем MIME-тип по расширению
const ext = fileData.filename.split('.').pop().toLowerCase();
let mimeType = 'application/x-tgsticker'; // по умолчанию для TGS
if (ext === 'webm') mimeType = 'video/webm';
if (ext === 'webp') mimeType = 'image/webp';
if (ext === 'png') mimeType = 'image/png';
// Загружаем в MinIO
await minioClient.putObject(BUCKET_NAME, `${documentId}`, fileData.buffer, fileData.buffer.length, {
'Content-Type': mimeType
});
// Создаём DocumentReadModel
const fileReference = Buffer.from([
0x01, 0x00, 0x00, 0x00,
(documentId >> 24) & 0xFF,
(documentId >> 16) & 0xFF,
(documentId >> 8) & 0xFF,
documentId & 0xFF
]);
const documentReadModelId = `document-${documentId}`;
const document = {
_id: documentReadModelId,
Id: documentReadModelId,
Version: 1,
DocumentId: documentId,
AccessHash: documentId,
FileReference: fileReference,
Date: Math.floor(Date.now() / 1000),
MimeType: mimeType,
Size: fileData.buffer.length,
Name: `${documentId}`,
DcId: 2,
Md5CheckSum: null,
CreatorId: null,
Thumbs: null,
ThumbId: null,
VideoThumbs: null,
VideoThumbId: null,
Attributes: null,
Attributes2: [{
_t: 'TDocumentAttributeSticker',
Alt: emoji,
Stickerset: {
_t: 'TInputStickerSetEmpty'
}
}]
};
await db.collection('ReadModel-DocumentReadModel').insertOne(document);
animationIds[assetType] = documentId;
// Сохраняем превью для анимации выбора
if (assetType === 'selectAnimation' && mimeType === 'application/x-tgsticker') {
try {
const decompressed = pako.inflate(fileData.buffer, { to: 'string' });
animationIds.selectAnimationPreview = JSON.parse(decompressed);
} catch (e) {
console.log(`Could not create preview for ${emoji}:`, e.message);
}
}
}
// Создаём или обновляем реакцию
const reaction = {
emoji: emoji,
title: emoji,
premium: premium,
inactive: false,
...animationIds,
createdAt: new Date()
};
// Проверяем, есть ли уже такая реакция
const existing = await db.collection('reactions').findOne({ emoji: emoji });
if (existing) {
await db.collection('reactions').updateOne({ _id: existing._id }, { $set: reaction });
} else {
await db.collection('reactions').insertOne(reaction);
}
results.push({
success: true,
emoji: emoji,
assets: Object.keys(animationIds)
});
} catch (error) {
console.error(`Error processing ${emoji}:`, error);
results.push({
success: false,
emoji: emoji,
error: error.message
});
}
}
res.json({
success: true,
results
});
} catch (error) {
console.error('Error bulk uploading reactions:', error);
res.status(500).json({ error: 'Failed to process ZIP file' });
} finally {
await client.close();
}
});
// Удалить реакцию
router.delete('/:id', async (req, res) => {
const client = new MongoClient(MONGO_URL);
try {
await client.connect();
const db = client.db(DB_NAME);
const reaction = await db.collection('reactions').findOne({ _id: new ObjectId(req.params.id) });
if (!reaction) {
return res.status(404).json({ error: 'Reaction not found' });
}
// Удаляем документы анимаций из MinIO и MongoDB
const animationFields = [
'staticIcon', 'appearAnimation', 'selectAnimation',
'activateAnimation', 'effectAnimation', 'aroundAnimation', 'centerIcon'
];
for (const field of animationFields) {
if (reaction[field]) {
try {
await minioClient.removeObject(BUCKET_NAME, `${reaction[field]}`);
await db.collection('eventflow-documentreadmodel').deleteOne({ DocumentId: reaction[field] });
} catch (e) {
console.log(`Failed to delete ${field}:`, e.message);
}
}
}
// Удаляем реакцию
await db.collection('reactions').deleteOne({ _id: new ObjectId(req.params.id) });
res.json({ success: true });
} catch (error) {
console.error('Error deleting reaction:', error);
res.status(500).json({ error: 'Failed to delete reaction' });
} finally {
await client.close();
}
});
export default router;
@@ -0,0 +1,347 @@
import express from 'express';
import { ObjectId } from 'mongodb';
const router = express.Router();
// Формирует ID уведомления
function createNotificationId() {
return `notification-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Возвращает текущую метку времени в формате Unix
function getCurrentTimestamp() {
return Math.floor(Date.now() / 1000);
}
// GET /api/service-notifications - получить все шаблоны уведомлений
router.get('/', async (req, res) => {
try {
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
const templates = await collection.find({}).sort({ CreatedDate: -1 }).toArray();
res.json({
success: true,
count: templates.length,
templates: templates
});
} catch (error) {
console.error('Error fetching notification templates:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET /api/service-notifications/:id - получить конкретный шаблон
router.get('/:id', async (req, res) => {
try {
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
const template = await collection.findOne({ Id: req.params.id });
if (!template) {
return res.status(404).json({
success: false,
error: 'Template not found'
});
}
res.json({
success: true,
template: template
});
} catch (error) {
console.error('Error fetching notification template:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// POST /api/service-notifications - создать новый шаблон
router.post('/', async (req, res) => {
try {
const { type, title, message, mediaUrl, mediaType, isPopup, isActive } = req.body;
if (!type || !title || !message) {
return res.status(400).json({
success: false,
error: 'Type, title, and message are required'
});
}
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
const notificationId = createNotificationId();
const currentDate = getCurrentTimestamp();
const template = {
_id: notificationId,
Id: notificationId,
Type: type,
Title: title,
Message: message,
MediaUrl: mediaUrl || null,
MediaType: mediaType || null,
IsPopup: isPopup !== undefined ? isPopup : true,
IsActive: isActive !== undefined ? isActive : true,
CreatedDate: currentDate,
UpdatedDate: null
};
await collection.insertOne(template);
console.log(`Created notification template: ${notificationId} (${type})`);
res.json({
success: true,
template: template
});
} catch (error) {
console.error('Error creating notification template:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// PUT /api/service-notifications/:id - обновить шаблон
router.put('/:id', async (req, res) => {
try {
const { type, title, message, mediaUrl, mediaType, isPopup, isActive } = req.body;
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
const updateFields = {
UpdatedDate: getCurrentTimestamp()
};
if (type !== undefined) updateFields.Type = type;
if (title !== undefined) updateFields.Title = title;
if (message !== undefined) updateFields.Message = message;
if (mediaUrl !== undefined) updateFields.MediaUrl = mediaUrl;
if (mediaType !== undefined) updateFields.MediaType = mediaType;
if (isPopup !== undefined) updateFields.IsPopup = isPopup;
if (isActive !== undefined) updateFields.IsActive = isActive;
const result = await collection.updateOne(
{ Id: req.params.id },
{ $set: updateFields }
);
if (result.matchedCount === 0) {
return res.status(404).json({
success: false,
error: 'Template not found'
});
}
const updatedTemplate = await collection.findOne({ Id: req.params.id });
console.log(`Updated notification template: ${req.params.id}`);
res.json({
success: true,
template: updatedTemplate
});
} catch (error) {
console.error('Error updating notification template:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// DELETE /api/service-notifications/:id - удалить шаблон
router.delete('/:id', async (req, res) => {
try {
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
const result = await collection.deleteOne({ Id: req.params.id });
if (result.deletedCount === 0) {
return res.status(404).json({
success: false,
error: 'Template not found'
});
}
console.log(`Deleted notification template: ${req.params.id}`);
res.json({
success: true,
message: 'Template deleted successfully'
});
} catch (error) {
console.error('Error deleting notification template:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// POST /api/service-notifications/:id/send - отправить уведомление пользователям
router.post('/:id/send', async (req, res) => {
try {
const { userIds, sendToAll } = req.body;
console.log(`Database name: ${req.db.databaseName}`);
console.log(`sendToAll: ${sendToAll}, userIds: ${userIds}`);
// Загружаем шаблон
const templatesCollection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
const template = await templatesCollection.findOne({ Id: req.params.id });
if (!template) {
return res.status(404).json({
success: false,
error: 'Template not found'
});
}
if (!template.IsActive) {
return res.status(400).json({
success: false,
error: 'Template is not active'
});
}
// Готовим данные уведомления
const notification = {
type: template.Type,
message: template.Message,
popup: template.IsPopup,
mediaUrl: template.MediaUrl,
mediaType: template.MediaType,
timestamp: getCurrentTimestamp()
};
let targetUserIds = [];
if (sendToAll) {
// Берём все ID пользователей из eventflow-userreadmodel (в EventFlow имена в нижнем регистре)
const usersCollection = req.db.collection('eventflow-userreadmodel');
const users = await usersCollection.find({}).project({ UserId: 1 }).toArray();
console.log(`Found ${users.length} users in database`);
console.log(`First 3 users:`, users.slice(0, 3));
targetUserIds = users.map(u => u.UserId).filter(id => id != null && id !== 777000 && id !== 569999);
console.log(`Sending notification to ALL users (${targetUserIds.length} users, excluding system users)`);
} else if (userIds && Array.isArray(userIds) && userIds.length > 0) {
targetUserIds = userIds.map(id => parseInt(id));
console.log(`Sending notification to ${targetUserIds.length} specific users`);
} else {
return res.status(400).json({
success: false,
error: 'Either sendToAll must be true or userIds must be provided'
});
}
// Сохраняем события уведомлений в коллекцию для последующей обработки
const notificationEventsCollection = req.db.collection('ServiceNotificationEvents');
const events = targetUserIds.map(userId => ({
userId: userId,
templateId: template.Id,
type: notification.type,
message: notification.message,
popup: notification.popup,
mediaUrl: notification.mediaUrl,
mediaType: notification.mediaType,
createdAt: new Date(),
processed: false
}));
if (events.length > 0) {
await notificationEventsCollection.insertMany(events);
}
console.log(`Created ${events.length} notification events for processing`);
// В реальной реализации здесь запускался бы C#-сервис.
// Пока возвращаем успех, а события обработает фоновый воркер.
res.json({
success: true,
message: `Notification queued for ${targetUserIds.length} users`,
targetUserCount: targetUserIds.length,
notification: notification
});
} catch (error) {
console.error('Error sending notification:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET /api/service-notifications/events/pending - получить необработанные события уведомлений
router.get('/events/pending', async (req, res) => {
try {
const collection = db.collection('ServiceNotificationEvents');
const pendingEvents = await collection
.find({ processed: false })
.sort({ createdAt: 1 })
.limit(100)
.toArray();
res.json({
success: true,
count: pendingEvents.length,
events: pendingEvents
});
} catch (error) {
console.error('Error fetching pending events:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// POST /api/service-notifications/events/:id/mark-processed - пометить событие как обработанное
router.post('/events/:id/mark-processed', async (req, res) => {
try {
const collection = db.collection('ServiceNotificationEvents');
const result = await collection.updateOne(
{ _id: new ObjectId(req.params.id) },
{
$set: {
processed: true,
processedAt: new Date()
}
}
);
if (result.matchedCount === 0) {
return res.status(404).json({
success: false,
error: 'Event not found'
});
}
res.json({
success: true,
message: 'Event marked as processed'
});
} catch (error) {
console.error('Error marking event as processed:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
export default router;
@@ -0,0 +1,96 @@
import express from 'express';
const router = express.Router();
// Получить настройки иконки заморозки
router.get('/frozen-icon', async (req, res) => {
try {
// Берём глобальные настройки иконки заморозки из БД
const settings = await req.db
.collection('global_settings')
.findOne({ key: 'frozen_icon' });
if (!settings) {
// Настройки по умолчанию
return res.json({
type: 'emoji',
emoji: '❄️',
documentId: null
});
}
res.json(settings.value);
} catch (error) {
console.error('Get frozen icon settings error:', error);
res.status(500).json({ error: error.message });
}
});
// Обновить настройки иконки заморозки
router.post('/frozen-icon', async (req, res) => {
try {
const { type, emoji, documentId } = req.body;
const settings = {
type: type || 'emoji',
emoji: emoji || '❄️',
documentId: documentId || null
};
// Сохраняем глобальные настройки
await req.db
.collection('global_settings')
.updateOne(
{ key: 'frozen_icon' },
{
$set: {
key: 'frozen_icon',
value: settings,
updatedAt: new Date()
}
},
{ upsert: true }
);
// Если выбрана анимация, применяем её ко всем замороженным пользователям
if (type === 'animation' && documentId) {
await req.db
.collection('eventflow-userreadmodel')
.updateMany(
{ Restricted: true },
{
$set: {
FrozenAnimationDocumentId: parseInt(documentId)
}
}
);
console.log(`Applied frozen animation ${documentId} to all restricted users`);
} else if (type === 'emoji') {
// При переключении на эмодзи убираем анимацию у всех пользователей
await req.db
.collection('eventflow-userreadmodel')
.updateMany(
{ Restricted: true },
{
$unset: {
FrozenAnimationDocumentId: ""
}
}
);
console.log(`Removed frozen animation from all restricted users`);
}
res.json({
success: true,
message: 'Frozen icon settings updated',
settings
});
} catch (error) {
console.error('Update frozen icon settings error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;
@@ -0,0 +1,326 @@
import express from 'express';
import Joi from 'joi';
const router = express.Router();
// Схема валидации
const sponsoredMessageSchema = Joi.object({
channelId: Joi.number().integer().positive().required(),
title: Joi.string().max(100).required(),
message: Joi.string().max(500).required(),
url: Joi.string().uri().required(),
buttonText: Joi.string().max(50).required(),
photoUrl: Joi.string().uri().optional().allow(null, ''),
sponsorInfo: Joi.string().max(200).optional().allow(null, ''),
additionalInfo: Joi.string().max(200).optional().allow(null, ''),
isActive: Joi.boolean().default(true),
recommended: Joi.boolean().default(false),
canReport: Joi.boolean().default(true),
postsBetween: Joi.number().integer().min(1).max(100).default(10),
expiresDate: Joi.number().integer().optional().allow(null)
});
// Список всех рекламных сообщений
router.get('/', async (req, res) => {
try {
const { channelId, isActive } = req.query;
const filter = {};
if (channelId) filter.ChannelId = parseInt(channelId);
if (isActive !== undefined) filter.IsActive = isActive === 'true';
const messages = await req.db
.collection('ReadModel-SponsoredMessageReadModel')
.find(filter)
.sort({ CreatedDate: -1 })
.toArray();
res.json({
success: true,
count: messages.length,
messages
});
} catch (error) {
console.error('Error fetching sponsored messages:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Получить рекламное сообщение по ID
router.get('/:id', async (req, res) => {
try {
const message = await req.db
.collection('ReadModel-SponsoredMessageReadModel')
.findOne({ Id: req.params.id });
if (!message) {
return res.status(404).json({
success: false,
error: 'Sponsored message not found'
});
}
res.json({
success: true,
message
});
} catch (error) {
console.error('Error fetching sponsored message:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Создать новое рекламное сообщение
router.post('/', async (req, res) => {
try {
const { error, value } = sponsoredMessageSchema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: error.details[0].message
});
}
const now = Math.floor(Date.now() / 1000);
const id = `sponsored-${value.channelId}-${now}`;
const sponsoredMessage = {
Id: id,
ChannelId: value.channelId,
Title: value.title,
Message: value.message,
Url: value.url,
ButtonText: value.buttonText,
PhotoUrl: value.photoUrl || null,
SponsorInfo: value.sponsorInfo || null,
AdditionalInfo: value.additionalInfo || null,
IsActive: value.isActive,
Recommended: value.recommended,
CanReport: value.canReport,
PostsBetween: value.postsBetween,
CreatedDate: now,
ExpiresDate: value.expiresDate || null,
DisplayCount: 0,
ClickCount: 0,
Version: 1
};
await req.db
.collection('ReadModel-SponsoredMessageReadModel')
.insertOne(sponsoredMessage);
console.log(`Created sponsored message: ${id} for channel ${value.channelId}`);
res.status(201).json({
success: true,
message: sponsoredMessage
});
} catch (error) {
console.error('Error creating sponsored message:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Обновить рекламное сообщение
router.put('/:id', async (req, res) => {
try {
const { error, value } = sponsoredMessageSchema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: error.details[0].message
});
}
const updateData = {
ChannelId: value.channelId,
Title: value.title,
Message: value.message,
Url: value.url,
ButtonText: value.buttonText,
PhotoUrl: value.photoUrl || null,
SponsorInfo: value.sponsorInfo || null,
AdditionalInfo: value.additionalInfo || null,
IsActive: value.isActive,
Recommended: value.recommended,
CanReport: value.canReport,
PostsBetween: value.postsBetween,
ExpiresDate: value.expiresDate || null
};
const result = await req.db
.collection('ReadModel-SponsoredMessageReadModel')
.updateOne(
{ Id: req.params.id },
{ $set: updateData, $inc: { Version: 1 } }
);
if (result.matchedCount === 0) {
return res.status(404).json({
success: false,
error: 'Sponsored message not found'
});
}
console.log(`Updated sponsored message: ${req.params.id}`);
res.json({
success: true,
message: 'Sponsored message updated successfully'
});
} catch (error) {
console.error('Error updating sponsored message:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Переключить статус активности
router.patch('/:id/toggle', async (req, res) => {
try {
const message = await req.db
.collection('ReadModel-SponsoredMessageReadModel')
.findOne({ Id: req.params.id });
if (!message) {
return res.status(404).json({
success: false,
error: 'Sponsored message not found'
});
}
const newStatus = !message.IsActive;
await req.db
.collection('ReadModel-SponsoredMessageReadModel')
.updateOne(
{ Id: req.params.id },
{ $set: { IsActive: newStatus }, $inc: { Version: 1 } }
);
console.log(`Toggled sponsored message ${req.params.id} to ${newStatus ? 'active' : 'inactive'}`);
res.json({
success: true,
isActive: newStatus
});
} catch (error) {
console.error('Error toggling sponsored message:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Удалить рекламное сообщение
router.delete('/:id', async (req, res) => {
try {
const result = await req.db
.collection('ReadModel-SponsoredMessageReadModel')
.deleteOne({ Id: req.params.id });
if (result.deletedCount === 0) {
return res.status(404).json({
success: false,
error: 'Sponsored message not found'
});
}
console.log(`Deleted sponsored message: ${req.params.id}`);
res.json({
success: true,
message: 'Sponsored message deleted successfully'
});
} catch (error) {
console.error('Error deleting sponsored message:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Статистика по каналу
router.get('/stats/:channelId', async (req, res) => {
try {
const channelId = parseInt(req.params.channelId);
const messages = await req.db
.collection('ReadModel-SponsoredMessageReadModel')
.find({ ChannelId: channelId })
.toArray();
const stats = {
totalMessages: messages.length,
activeMessages: messages.filter(m => m.IsActive).length,
totalDisplays: messages.reduce((sum, m) => sum + (m.DisplayCount || 0), 0),
totalClicks: messages.reduce((sum, m) => sum + (m.ClickCount || 0), 0),
clickThroughRate: 0
};
if (stats.totalDisplays > 0) {
stats.clickThroughRate = ((stats.totalClicks / stats.totalDisplays) * 100).toFixed(2);
}
res.json({
success: true,
channelId,
stats
});
} catch (error) {
console.error('Error fetching stats:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Учёт перехода по ссылке (вызывается из ссылки отслеживания)
router.post('/click/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await req.db
.collection('ReadModel-SponsoredMessageReadModel')
.updateOne(
{ Id: id },
{ $inc: { ClickCount: 1 } }
);
if (result.matchedCount === 0) {
return res.status(404).json({
success: false,
error: 'Sponsored message not found'
});
}
console.log(`Click tracked for sponsored message: ${id}`);
res.json({
success: true,
message: 'Click tracked successfully'
});
} catch (error) {
console.error('Error tracking click:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
export default router;
+385
View File
@@ -0,0 +1,385 @@
import express from 'express';
import { ObjectId, Long } from 'mongodb';
import { v5 as uuidv5, v4 as uuidv4 } from 'uuid';
const router = express.Router();
// Пространство имён EventFlow (получено через рефлексию из EventFlow.Core.GuidFactories)
const EVENTFLOW_COMMANDS_NAMESPACE = '4286d89f-7f92-430b-8e00-e468fe3c3f59';
// POST /api/stars/issue - начислить звёзды пользователю
router.post('/issue', async (req, res) => {
try {
const { userId, amount, reason } = req.body;
// Проверка входных данных
if (!userId || !amount || amount <= 0) {
return res.status(400).json({
success: false,
error: 'Invalid request. userId and positive amount are required.'
});
}
const db = req.db;
const eventsCollection = db.collection('eventflow.events');
const starsCollection = db.collection('eventflow-starsreadmodel');
const userCollection = db.collection('eventflow-userreadmodel');
// Проверяем, существует ли пользователь
const user = await userCollection.findOne({ UserId: parseInt(userId) });
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// Генерируем детерминированный ID на основе пространства имён EventFlow
const guid = uuidv5(`stars_${userId}`, EVENTFLOW_COMMANDS_NAMESPACE);
const aggregateId = `stars-${guid}`;
const documentId = aggregateId;
console.log(`Generated StarsAggregate ID: ${aggregateId} for user ${userId}`);
// --- Обновление write-модели EventFlow ---
// Находим последнее событие агрегата, чтобы определить номер в последовательности
const lastEvent = await eventsCollection.findOne(
{ AggregateId: aggregateId },
{ sort: { AggregateSequenceNumber: -1 }, projection: { AggregateSequenceNumber: 1, Data: 1 } }
);
let currentSequence = lastEvent ? lastEvent.AggregateSequenceNumber : 0;
let currentBalance = 0;
// Разбираем Data, чтобы получить текущий баланс
if (lastEvent && lastEvent.Data) {
try {
const data = JSON.parse(lastEvent.Data);
if (data.NewBalance !== undefined) {
currentBalance = data.NewBalance;
} else if (data.Balance !== undefined) {
currentBalance = data.Balance;
}
} catch (e) {
// Игнорируем ошибки разбора
}
}
const eventsToInsert = [];
const now = new Date();
const batchId = uuidv4();
const timestampEpoch = Math.floor(now.getTime() / 1000);
const baseId = Date.now();
// 1. Если агрегата ещё нет, создаём StarsAccountCreatedEvent
if (currentSequence === 0) {
currentSequence++;
const createEventData = {
PeerId: parseInt(userId),
RequestInfo: {
DeviceType: 1,
AddRequestIdToCache: true
}
};
const createMetadata = {
timestamp: now.toISOString(),
aggregate_sequence_number: currentSequence.toString(),
aggregate_name: 'StarsAggregate',
aggregate_id: aggregateId,
event_id: `event-${new ObjectId().toString()}`,
timestamp_epoch: timestampEpoch.toString(),
batch_id: batchId,
source_id: `command-${new ObjectId().toString()}`,
event_name: 'StarsAccountCreatedEvent',
event_version: '1'
};
eventsToInsert.push({
_id: baseId,
BatchId: batchId,
AggregateId: aggregateId,
AggregateName: 'StarsAggregate',
AggregateSequenceNumber: currentSequence,
Data: JSON.stringify(createEventData),
Metadata: JSON.stringify(createMetadata)
});
console.log(`Creating new StarsAggregate for user ${userId}`);
}
// 2. Создаём StarsAddedEvent
currentSequence++;
const newBalance = currentBalance + parseInt(amount);
const transactionId = uuidv4();
const eventData = {
PeerId: parseInt(userId),
Amount: parseInt(amount),
TransactionId: transactionId,
Reason: reason || 'Admin issued stars',
NewBalance: newBalance,
RequestInfo: {
DeviceType: 1,
AddRequestIdToCache: true
}
};
const metadata = {
timestamp: now.toISOString(),
aggregate_sequence_number: currentSequence.toString(),
aggregate_name: 'StarsAggregate',
aggregate_id: aggregateId,
event_id: `event-${new ObjectId().toString()}`,
timestamp_epoch: timestampEpoch.toString(),
batch_id: batchId,
source_id: `command-${new ObjectId().toString()}`,
event_name: 'StarsAddedEvent',
event_version: '1'
};
eventsToInsert.push({
_id: baseId + eventsToInsert.length, // сдвигаем, если уже добавили StarsAccountCreatedEvent
BatchId: batchId,
AggregateId: aggregateId,
AggregateName: 'StarsAggregate',
AggregateSequenceNumber: currentSequence,
Data: JSON.stringify(eventData),
Metadata: JSON.stringify(metadata)
});
// Сохраняем события
if (eventsToInsert.length > 0) {
await eventsCollection.insertMany(eventsToInsert);
console.log(`Inserted StarsAddedEvent for user ${userId}: +${amount} stars, new balance: ${newBalance}`);
}
// --- Обновление read-модели (для немедленного отклика интерфейса) ---
// Это может дублировать обновление от EventFlow, но повышает отзывчивость UI
const transaction = {
Id: new ObjectId().toString(),
Amount: parseInt(amount),
Date: now,
Type: 'TopUp',
Reason: reason || 'Admin issued stars',
TransactionId: transactionId
};
// Проверяем, есть ли read-модель (должна быть, если агрегат существует, но может быть не синхронизирована)
let starsAccount = await starsCollection.findOne({ _id: documentId });
if (!starsAccount) {
// Создаём новый счёт звёзд в read-модели
starsAccount = {
_id: documentId,
Id: documentId,
PeerId: parseInt(userId), // храним как обычный int64, не Long
Balance: newBalance, // храним как обычный int64, не Long
Transactions: [transaction],
Version: Long.fromNumber(1)
};
await starsCollection.insertOne(starsAccount);
console.log(`Created new StarsReadModel with PeerId=${userId} (as int64)`);
} else {
// Обновляем существующий счёт
const currentBalance = starsAccount.Balance instanceof Long
? starsAccount.Balance.toNumber()
: parseInt(starsAccount.Balance);
await starsCollection.updateOne(
{ _id: documentId },
{
$set: {
Balance: currentBalance + parseInt(amount), // храним как обычный int64
PeerId: parseInt(userId) // следим, чтобы PeerId был обычным int64
},
$push: { Transactions: transaction }
}
);
console.log(`Updated StarsReadModel with PeerId=${userId}, newBalance=${currentBalance + parseInt(amount)}`);
}
res.json({
success: true,
data: {
userId: parseInt(userId),
userName: user.UserName || null,
firstName: user.FirstName || null,
newBalance: newBalance.toString(),
transaction: {
amount: parseInt(amount),
reason: reason || 'Admin issued stars',
date: transaction.Date
}
}
});
} catch (error) {
console.error('Error issuing stars:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET /api/stars/user/:userId - баланс звёзд и транзакции пользователя
router.get('/user/:userId', async (req, res) => {
try {
const { userId } = req.params;
const db = req.db;
// Получаем сведения о пользователе
const userCollection = db.collection('eventflow-userreadmodel');
const user = await userCollection.findOne({ UserId: parseInt(userId) });
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// Пытаемся найти события StarsAggregate для этого пользователя
const eventsCollection = db.collection('eventflow.events');
const existingEvent = await eventsCollection.findOne({
AggregateName: 'StarsAggregate',
'Data': { $regex: `"PeerId":${userId}` }
});
let starsAccount = null;
if (existingEvent) {
const aggregateId = existingEvent.AggregateId;
const starsCollection = db.collection('eventflow-starsreadmodel');
starsAccount = await starsCollection.findOne({ _id: aggregateId });
}
res.json({
success: true,
data: {
user: {
userId: user.UserId,
userName: user.UserName || null,
firstName: user.FirstName || null,
lastName: user.LastName || null,
phoneNumber: user.PhoneNumber || null
},
balance: starsAccount?.Balance?.toString() || "0",
transactions: starsAccount?.Transactions || []
}
});
} catch (error) {
console.error('Error getting user stars:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET /api/stars/recent - последние транзакции звёзд по всем пользователям
router.get('/recent', async (req, res) => {
try {
const { limit = 20 } = req.query;
const db = req.db;
const starsCollection = db.collection('eventflow-starsreadmodel');
// Собираем последние транзакции
const accounts = await starsCollection
.find({ Transactions: { $exists: true, $ne: [] } })
.limit(parseInt(limit))
.sort({ 'Transactions.Date': -1 })
.toArray();
// Разворачиваем транзакции в плоский список
const recentTransactions = [];
for (const account of accounts) {
if (account.Transactions) {
for (const tx of account.Transactions.slice(-5)) { // последние 5 на пользователя
recentTransactions.push({
userId: account.PeerId.toString(),
...tx
});
}
}
}
// Сортируем по дате
recentTransactions.sort((a, b) => new Date(b.Date) - new Date(a.Date));
res.json({
success: true,
data: recentTransactions.slice(0, parseInt(limit))
});
} catch (error) {
console.error('Error getting recent transactions:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// POST /api/stars/notify-gift-received - уведомить пользователя о полученном подарке
router.post('/notify-gift-received', async (req, res) => {
try {
const { recipientUserId, senderUserId, giftId, stars } = req.body;
if (!recipientUserId || !senderUserId || !giftId) {
return res.status(400).json({
success: false,
error: 'recipientUserId, senderUserId, and giftId are required'
});
}
const db = req.db;
// Получаем сведения о получателе
const userCollection = db.collection('eventflow-userreadmodel');
const recipient = await userCollection.findOne({ UserId: parseInt(recipientUserId) });
const sender = await userCollection.findOne({ UserId: parseInt(senderUserId) });
if (!recipient) {
return res.status(404).json({
success: false,
error: 'Recipient not found'
});
}
console.log(`Notification requested for user ${recipientUserId}: Gift ${giftId} from user ${senderUserId}`);
// Само уведомление отправляет MessageDomainEventHandler при обработке
// InboxMessageCreatedEvent. Этот эндпоинт нужен для тестов и отладки.
res.json({
success: true,
message: `Notification logged for user ${recipientUserId}`,
data: {
recipient: {
userId: recipient.UserId,
userName: recipient.UserName || null,
firstName: recipient.FirstName || null
},
sender: sender ? {
userId: sender.UserId,
userName: sender.UserName || null,
firstName: sender.FirstName || null
} : null,
giftId: parseInt(giftId),
stars: parseInt(stars || 0)
}
});
} catch (error) {
console.error('Error processing gift notification:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
export default router;
+102
View File
@@ -0,0 +1,102 @@
import express from 'express';
const router = express.Router();
// GET statistics
router.get('/', async (req, res) => {
try {
const giftsCollection = req.db.collection('AvailableStarGiftReadModel');
const sentGiftsCollection = req.db.collection('StarGiftReadModel');
const [
totalGifts,
limitedGifts,
soldOutGifts,
totalSentGifts,
totalStarsEarned
] = await Promise.all([
giftsCollection.countDocuments(),
giftsCollection.countDocuments({ Limited: true }),
giftsCollection.countDocuments({ SoldOut: true }),
sentGiftsCollection.countDocuments(),
sentGiftsCollection.aggregate([
{ $group: { _id: null, total: { $sum: '$Stars' } } }
]).toArray()
]);
const mostPopular = await sentGiftsCollection.aggregate([
{ $group: { _id: '$GiftId', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 5 }
]).toArray();
res.json({
totalGifts,
limitedGifts,
soldOutGifts,
availableGifts: totalGifts - soldOutGifts,
totalSentGifts,
totalStarsEarned: totalStarsEarned[0]?.total || 0,
mostPopular
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET sent gifts statistics
router.get('/sent', async (req, res) => {
try {
const sentGiftsCollection = req.db.collection('StarGiftReadModel');
const stats = await sentGiftsCollection.aggregate([
{
$facet: {
byDay: [
{
$group: {
_id: {
$dateToString: { format: '%Y-%m-%d', date: '$CreatedAt' }
},
count: { $sum: 1 },
stars: { $sum: '$Stars' }
}
},
{ $sort: { _id: -1 } },
{ $limit: 30 }
],
byGift: [
{
$group: {
_id: '$GiftId',
count: { $sum: 1 },
stars: { $sum: '$Stars' }
}
},
{ $sort: { count: -1 } }
],
conversion: [
{
$group: {
_id: null,
total: { $sum: 1 },
converted: {
$sum: { $cond: ['$Converted', 1, 0] }
},
saved: {
$sum: { $cond: ['$Saved', 1, 0] }
}
}
}
]
}
}
]).toArray();
res.json(stats[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
import express from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
const router = express.Router();
// Настраиваем multer для загрузки файлов
const upload = multer({
dest: 'uploads/',
limits: {
fileSize: 10 * 1024 * 1024 // ограничение 10 МБ
},
fileFilter: (req, file, cb) => {
// Разрешаем JSON-файлы для анимаций
if (file.mimetype === 'application/json' || file.originalname.endsWith('.json')) {
cb(null, true);
} else {
cb(new Error('Only JSON files are allowed for animations'));
}
}
});
// Загрузить документ в Telegram
router.post('/document', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const { type, title } = req.body;
const filePath = req.file.path;
const originalName = req.file.originalname;
console.log(`Uploading document: ${originalName}, type: ${type}, title: ${title}`);
try {
// Читаем содержимое файла
const fileBuffer = fs.readFileSync(filePath);
// Для JSON-анимаций проверяем содержимое
if (type === 'animation' && originalName.endsWith('.json')) {
try {
const jsonContent = JSON.parse(fileBuffer.toString());
if (!jsonContent.v || !jsonContent.layers) {
throw new Error('Invalid Lottie JSON: missing required fields (v, layers)');
}
console.log(`Valid Lottie JSON: version=${jsonContent.v}, layers=${jsonContent.layers?.length}`);
} catch (jsonError) {
return res.status(400).json({
success: false,
error: 'Invalid JSON animation: ' + jsonError.message
});
}
}
// Генерируем ID документа (по метке времени, как у остальных документов)
const documentId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
// Создаём документ в MongoDB
const document = {
_id: `document-${documentId}`,
Id: `document-${documentId}`,
DocumentId: documentId,
AccessHash: Math.floor(Math.random() * 9223372036854775807), // случайное 64-битное значение
FileReference: Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]), // 8 байт
Date: Math.floor(Date.now() / 1000),
MimeType: type === 'animation' ? 'application/json' : 'application/octet-stream',
Size: fileBuffer.length,
DcId: 2, // дата-центр для медиа
Attributes2: [
{
ConstructorId: 0x6319d612, // TDocumentAttributeSticker
Alt: type === 'animation' ? '❄️' : '📄',
Stickerset: {
ConstructorId: 0x83cf7966, // TInputStickerSetEmpty
}
}
],
Thumbs: [],
VideoThumbs: []
};
// Сохраняем документ в MongoDB
await req.db
.collection('eventflow-documentreadmodel')
.insertOne(document);
// TODO: загрузить файл в хранилище MinIO.
// Пока создаём только запись документа.
// В продакшене сюда нужно заливать fileBuffer в MinIO по пути `${documentId}`.
console.log(`Document created: ID=${documentId}, Size=${fileBuffer.length} bytes`);
res.json({
success: true,
documentId: documentId,
message: `Document uploaded successfully`,
size: fileBuffer.length
});
} finally {
// Удаляем загруженный файл
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
} catch (error) {
console.error('Upload document error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
export default router;
+413
View File
@@ -0,0 +1,413 @@
import express from 'express';
const router = express.Router();
// Список всех пользователей с постраничной разбивкой
router.get('/', async (req, res) => {
try {
const { page = 1, limit = 20, search = '' } = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
const filter = {};
if (search) {
filter.$or = [
{ FirstName: { $regex: search, $options: 'i' } },
{ LastName: { $regex: search, $options: 'i' } },
{ UserName: { $regex: search, $options: 'i' } },
{ Phone: { $regex: search, $options: 'i' } }
];
}
const [users, total] = await Promise.all([
req.db.collection('eventflow-userreadmodel')
.find(filter)
.sort({ UserId: -1 })
.skip(skip)
.limit(parseInt(limit))
.toArray(),
req.db.collection('eventflow-userreadmodel').countDocuments(filter)
]);
res.json({
success: true,
users,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / parseInt(limit))
}
});
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({ error: error.message });
}
});
// Поиск пользователей по телефону или имени пользователя
router.get('/search', async (req, res) => {
try {
const { phone, username } = req.query;
if (!phone && !username) {
return res.status(400).json({ error: 'phone or username parameter required' });
}
let query = {};
if (phone) {
// Поиск по номеру телефона
query.PhoneNumber = phone;
} else if (username) {
// Поиск по имени пользователя (без учёта регистра)
query.UserName = new RegExp(`^${username}$`, 'i');
}
const users = await req.db
.collection('eventflow-userreadmodel')
.find(query)
.limit(10)
.toArray();
res.json(users);
} catch (error) {
console.error('User search error:', error);
res.status(500).json({ error: error.message });
}
});
// Получить пользователя по ID
router.get('/:userId', async (req, res) => {
try {
const userId = parseInt(req.params.userId);
const user = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: userId });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({ error: error.message });
}
});
// Заморозить (ограничить) аккаунт пользователя
router.post('/:userId/freeze', async (req, res) => {
try {
const userId = parseInt(req.params.userId);
const { reason = 'Account restricted for violating Terms of Service' } = req.body;
const user = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: userId });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Переводим пользователя в замороженное (ограниченное) состояние
await req.db
.collection('eventflow-userreadmodel')
.updateOne(
{ UserId: userId },
{
$set: {
Restricted: true,
RestrictionReason: reason
}
}
);
res.json({
success: true,
message: `User ${userId} has been frozen`,
userId,
reason
});
} catch (error) {
console.error('Freeze user error:', error);
res.status(500).json({ error: error.message });
}
});
// Разморозить (снять ограничение) аккаунт пользователя
router.post('/:userId/unfreeze', async (req, res) => {
try {
const userId = parseInt(req.params.userId);
const user = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: userId });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Снимаем ограничение
await req.db
.collection('eventflow-userreadmodel')
.updateOne(
{ UserId: userId },
{
$set: {
Restricted: false,
RestrictionReason: null
}
}
);
res.json({
success: true,
message: `User ${userId} has been unfrozen`,
userId
});
} catch (error) {
console.error('Unfreeze user error:', error);
res.status(500).json({ error: error.message });
}
});
// Удалить аккаунт пользователя (пометить как удалённый)
router.delete('/:userId', async (req, res) => {
try {
const userId = parseInt(req.params.userId);
const user = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: userId });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Помечаем пользователя как удалённого
await req.db
.collection('eventflow-userreadmodel')
.updateOne(
{ UserId: userId },
{
$set: {
IsDeleted: true,
Restricted: true,
RestrictionReason: 'Account deleted by administrator'
}
}
);
res.json({
success: true,
message: `User ${userId} has been deleted`,
userId
});
} catch (error) {
console.error('Delete user error:', error);
res.status(500).json({ error: error.message });
}
});
// Задать анимацию заморозки для пользователя
router.post('/:userId/frozen-animation', async (req, res) => {
try {
const userId = parseInt(req.params.userId);
const { documentId } = req.body;
if (!documentId) {
return res.status(400).json({ error: 'Document ID is required' });
}
const user = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: userId });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Сохраняем ID документа анимации заморозки
await req.db
.collection('eventflow-userreadmodel')
.updateOne(
{ UserId: userId },
{
$set: {
FrozenAnimationDocumentId: parseInt(documentId)
}
}
);
res.json({
success: true,
message: `Frozen animation set for user ${userId}`,
userId,
documentId: parseInt(documentId)
});
} catch (error) {
console.error('Set frozen animation error:', error);
res.status(500).json({ error: error.message });
}
});
// Убрать анимацию заморозки у пользователя
router.delete('/:userId/frozen-animation', async (req, res) => {
try {
const userId = parseInt(req.params.userId);
const user = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: userId });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Убираем анимацию заморозки
await req.db
.collection('eventflow-userreadmodel')
.updateOne(
{ UserId: userId },
{
$unset: {
FrozenAnimationDocumentId: ""
}
}
);
res.json({
success: true,
message: `Frozen animation removed from user ${userId}`,
userId
});
} catch (error) {
console.error('Remove frozen animation error:', error);
res.status(500).json({ error: error.message });
}
});
// Выдать пользователю premium
router.post('/:userId/premium', async (req, res) => {
try {
const userId = parseInt(req.params.userId);
const user = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: userId });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Выдаём статус premium
await req.db
.collection('eventflow-userreadmodel')
.updateOne(
{ UserId: userId },
{
$set: {
Premium: true,
PremiumGifts: true
}
}
);
res.json({
success: true,
message: `Premium given to user ${userId}`,
userId
});
} catch (error) {
console.error('Give premium error:', error);
res.status(500).json({ error: error.message });
}
});
// Снять premium с пользователя
router.delete('/:userId/premium', async (req, res) => {
try {
const userId = parseInt(req.params.userId);
const user = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: userId });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Снимаем статус premium
await req.db
.collection('eventflow-userreadmodel')
.updateOne(
{ UserId: userId },
{
$set: {
Premium: false,
PremiumGifts: false
}
}
);
res.json({
success: true,
message: `Premium removed from user ${userId}`,
userId
});
} catch (error) {
console.error('Remove premium error:', error);
res.status(500).json({ error: error.message });
}
});
// Получить настройки иконки заморозки
router.get('/settings/frozen-icon', async (req, res) => {
try {
const settings = await req.db
.collection('admin-settings')
.findOne({ key: 'frozen_icon' });
res.json({
type: settings?.type || 'emoji', // значение 'emoji' или 'animation'
emoji: settings?.emoji || '❄️',
animationUrl: settings?.animationUrl || null
});
} catch (error) {
console.error('Get frozen icon error:', error);
res.status(500).json({ error: error.message });
}
});
// Обновить настройки иконки заморозки
router.post('/settings/frozen-icon', async (req, res) => {
try {
const { type, emoji, animationUrl } = req.body;
await req.db
.collection('admin-settings')
.updateOne(
{ key: 'frozen_icon' },
{
$set: {
key: 'frozen_icon',
type: type || 'emoji',
emoji: emoji || '❄️',
animationUrl: animationUrl || null,
updatedAt: new Date()
}
},
{ upsert: true }
);
res.json({
success: true,
message: 'Frozen icon settings updated'
});
} catch (error) {
console.error('Update frozen icon error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;
@@ -0,0 +1,679 @@
import express from 'express';
import multer from 'multer';
import { promises as fs } from 'fs';
import path from 'path';
import Joi from 'joi';
import * as minioHelper from '../utils/minioHelper.js';
const router = express.Router();
// Настройка загрузки файлов для иконок TGS
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = 'uploads/verification-icons';
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, `verification-icon-${uniqueSuffix}${path.extname(file.originalname)}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowedTypes = ['application/json', 'application/octet-stream', 'application/x-tgsticker'];
const allowedExts = ['.json', '.tgs'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Only .json and .tgs files are allowed'));
}
}
});
// Схемы валидации
const botVerifierSchema = Joi.object({
botUserId: Joi.number().integer().positive().required(),
iconEmojiId: Joi.number().integer().positive().required(),
companyName: Joi.string().min(1).max(200).required(),
canModifyCustomDescription: Joi.boolean().default(false)
});
const verificationSchema = Joi.object({
targetUserId: Joi.number().integer().positive().required(),
botUserId: Joi.number().integer().positive().optional(),
customDescription: Joi.string().max(500).optional().allow(null, '')
});
// GET /api/verification/bots - Get all bot verifiers
router.get('/bots', async (req, res) => {
try {
const verifiers = await req.db
.collection('ReadModel-BotVerifierReadModel')
.find({ IsActive: true })
.toArray();
res.json({
success: true,
data: verifiers
});
} catch (error) {
console.error('Error fetching bot verifiers:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch bot verifiers'
});
}
});
// GET /api/verification/bots/:botUserId - Get specific bot verifier
router.get('/bots/:botUserId', async (req, res) => {
try {
const { botUserId } = req.params;
const verifier = await req.db
.collection('ReadModel-BotVerifierReadModel')
.findOne({ BotUserId: parseInt(botUserId), IsActive: true });
if (!verifier) {
return res.status(404).json({
success: false,
error: 'Bot verifier not found'
});
}
res.json({
success: true,
data: verifier
});
} catch (error) {
console.error('Error fetching bot verifier:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch bot verifier'
});
}
});
// POST /api/verification/bots - Create new bot verifier
router.post('/bots', async (req, res) => {
try {
const { error, value } = botVerifierSchema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: error.details[0].message
});
}
// Check if bot user exists
const botUser = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: value.botUserId, Bot: true });
if (!botUser) {
return res.status(404).json({
success: false,
error: 'Bot user not found'
});
}
// Check if verifier already exists
const existing = await req.db
.collection('ReadModel-BotVerifierReadModel')
.findOne({ BotUserId: value.botUserId });
const verifier = {
Id: `bot_${value.botUserId}`,
BotUserId: value.botUserId,
IconEmojiId: value.iconEmojiId,
CompanyName: value.companyName,
CanModifyCustomDescription: value.canModifyCustomDescription,
IsActive: true,
CreatedAt: existing?.CreatedAt || new Date(),
UpdatedAt: new Date()
};
if (existing) {
// Update existing verifier
await req.db
.collection('ReadModel-BotVerifierReadModel')
.updateOne(
{ BotUserId: value.botUserId },
{ $set: verifier }
);
} else {
// Create new verifier
await req.db
.collection('ReadModel-BotVerifierReadModel')
.insertOne(verifier);
}
res.status(201).json({
success: true,
data: verifier
});
} catch (error) {
console.error('Error creating bot verifier:', error);
res.status(500).json({
success: false,
error: 'Failed to create bot verifier'
});
}
});
// PUT /api/verification/bots/:botUserId - Update bot verifier
router.put('/bots/:botUserId', async (req, res) => {
try {
const { botUserId } = req.params;
const { error, value } = botVerifierSchema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: error.details[0].message
});
}
const result = await req.db
.collection('ReadModel-BotVerifierReadModel')
.findOneAndUpdate(
{ BotUserId: parseInt(botUserId) },
{
$set: {
IconEmojiId: value.iconEmojiId,
CompanyName: value.companyName,
CanModifyCustomDescription: value.canModifyCustomDescription,
UpdatedAt: new Date()
}
},
{ returnDocument: 'after' }
);
if (!result) {
return res.status(404).json({
success: false,
error: 'Bot verifier not found'
});
}
res.json({
success: true,
data: result
});
} catch (error) {
console.error('Error updating bot verifier:', error);
res.status(500).json({
success: false,
error: 'Failed to update bot verifier'
});
}
});
// DELETE /api/verification/bots/:botUserId - Deactivate bot verifier
router.delete('/bots/:botUserId', async (req, res) => {
try {
const { botUserId } = req.params;
const result = await req.db
.collection('ReadModel-BotVerifierReadModel')
.findOneAndUpdate(
{ BotUserId: parseInt(botUserId) },
{
$set: {
IsActive: false,
UpdatedAt: new Date()
}
},
{ returnDocument: 'after' }
);
if (!result) {
return res.status(404).json({
success: false,
error: 'Bot verifier not found'
});
}
res.json({
success: true,
data: result
});
} catch (error) {
console.error('Error deactivating bot verifier:', error);
res.status(500).json({
success: false,
error: 'Failed to deactivate bot verifier'
});
}
});
// GET /api/verification/users/:userId - Get user verification status
router.get('/users/:userId', async (req, res) => {
try {
const { userId } = req.params;
const verification = await req.db
.collection('ReadModel-BotVerificationReadModel')
.findOne({ TargetType: 1, TargetId: parseInt(userId) });
if (!verification) {
return res.json({
success: true,
data: null
});
}
res.json({
success: true,
data: verification
});
} catch (error) {
console.error('Error fetching user verification:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch verification status'
});
}
});
// POST /api/verification/users/:userId - Set user verification
router.post('/users/:userId', async (req, res) => {
try {
const { userId } = req.params;
const { error, value } = verificationSchema.validate({
...req.body,
targetUserId: parseInt(userId)
});
if (error) {
return res.status(400).json({
success: false,
error: error.details[0].message
});
}
// Check if target user exists
const targetUser = await req.db
.collection('eventflow-userreadmodel')
.findOne({ UserId: value.targetUserId });
if (!targetUser) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// Get bot verifier
const botUserId = value.botUserId || req.body.botUserId;
const verifier = await req.db
.collection('ReadModel-BotVerifierReadModel')
.findOne({ BotUserId: botUserId, IsActive: true });
if (!verifier) {
return res.status(404).json({
success: false,
error: 'Bot verifier not found or inactive'
});
}
// Проверяем право задавать собственное описание
if (value.customDescription && !verifier.CanModifyCustomDescription) {
return res.status(403).json({
success: false,
error: 'Bot verifier cannot modify custom description'
});
}
// По умолчанию описание берётся из CompanyName.
// Если задано непустое CustomDescription, оно заменяет описание по умолчанию.
const defaultDescription = verifier.CompanyName;
const customDescription = value.customDescription && value.customDescription.trim() !== ''
? value.customDescription.trim()
: null;
// Update UserReadModel with verification (both collections for compatibility)
const updateData = {
BotVerifierId: parseInt(verifier.BotUserId),
BotVerificationIcon: parseInt(verifier.IconEmojiId)
};
// Check if UserReadModel exists
const existingUser = await req.db.collection('ReadModel-UserReadModel').findOne({ UserId: value.targetUserId });
if (!existingUser) {
console.log(`UserReadModel does not exist for user ${value.targetUserId}`);
console.log(`UserReadModel создаётся Command Server при регистрации или обновлении пользователя`);
console.log(`Пока создаём минимальный UserReadModel только с полями верификации`);
// Создаём минимальный UserReadModel с полями верификации
await req.db.collection('ReadModel-UserReadModel').insertOne({
_id: `user-${value.targetUserId}`,
Id: `user-${value.targetUserId}`,
UserId: value.targetUserId,
BotVerifierId: parseInt(verifier.BotUserId),
BotVerificationIcon: parseInt(verifier.IconEmojiId),
Version: 1
});
console.log(`Created minimal UserReadModel for user ${value.targetUserId}`);
} else {
// Обновляем существующий UserReadModel и увеличиваем Version, чтобы сбросить кэш
const currentVersion = existingUser.Version || 1;
await req.db.collection('ReadModel-UserReadModel').updateOne(
{ UserId: value.targetUserId },
{
$set: updateData,
$inc: { Version: 1 } // увеличиваем версию, чтобы сбросить кэш Query Server
}
);
console.log(`Updated existing UserReadModel for user ${value.targetUserId} (Version: ${currentVersion} -> ${currentVersion + 1})`);
}
// Также обновляем коллекцию eventflow
await req.db.collection('eventflow-userreadmodel').updateOne(
{ UserId: value.targetUserId },
{ $set: updateData },
{ upsert: true }
);
// Создаём или обновляем BotVerificationReadModel
const verificationId = `verification_1_${value.targetUserId}`; // формат: verification_{TargetType}_{TargetId}
const verification = {
_id: verificationId, // явно задаём _id строкой
Id: verificationId,
TargetType: 1, // пользователь
TargetId: value.targetUserId,
BotVerifierId: verifier.BotUserId, // используем BotVerifierId (имя поля в C#-ReadModel)
IconEmojiId: verifier.IconEmojiId,
Description: defaultDescription, // всегда задаём описание по умолчанию из CompanyName
CustomDescription: customDescription, // null или собственный текст
VerifiedAt: new Date(),
UpdatedAt: new Date()
};
console.log(`Creating verification: Description="${defaultDescription}", CustomDescription=${customDescription ? `"${customDescription}"` : 'null'}`);
// Удаляем старую запись, если она есть (чтобы избежать конфликта _id)
await req.db.collection('ReadModel-BotVerificationReadModel').deleteOne({
TargetType: 1,
TargetId: value.targetUserId
});
// Вставляем новую запись
await req.db.collection('ReadModel-BotVerificationReadModel').insertOne(verification);
// Сбрасываем кэш UserReadModel, обновляя служебное поле:
// это заставляет Query Server перечитать пользователя из MongoDB
await req.db.collection('ReadModel-UserReadModel').updateOne(
{ UserId: value.targetUserId },
{ $set: { _cacheInvalidated: new Date() } }
);
console.log(`Verification created successfully for user ${value.targetUserId}`);
res.json({
success: true,
data: verification,
warning: 'Please restart messenger-query-server to see changes: docker-compose restart messenger-query-server'
});
} catch (error) {
console.error('Error setting user verification:', error);
res.status(500).json({
success: false,
error: 'Failed to set user verification'
});
}
});
// GET /api/verification/users/:userId/debug - отладочные данные о верификации пользователя
router.get('/users/:userId/debug', async (req, res) => {
try {
const userId = parseInt(req.params.userId);
// Проверяем UserReadModel
const userReadModel = await req.db.collection('ReadModel-UserReadModel').findOne({ UserId: userId });
// Проверяем BotVerificationReadModel
const verification = await req.db.collection('ReadModel-BotVerificationReadModel').findOne({
TargetType: 1,
TargetId: userId
});
res.json({
success: true,
data: {
userReadModel: {
BotVerifierId: userReadModel?.BotVerifierId || null,
BotVerificationIcon: userReadModel?.BotVerificationIcon || null
},
verification: verification || null
}
});
} catch (error) {
console.error('Error debugging verification:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// DELETE /api/verification/users/:userId - Remove user verification
router.delete('/users/:userId', async (req, res) => {
try {
const { userId } = req.params;
// Убираем верификацию из UserReadModel (в обеих коллекциях)
const unsetData = {
BotVerifierId: "",
BotVerificationIcon: ""
};
// Запоминаем текущую версию до обновления
const existingUser = await req.db.collection('ReadModel-UserReadModel').findOne({ UserId: parseInt(userId) });
const currentVersion = existingUser?.Version || 1;
await Promise.all([
req.db.collection('eventflow-userreadmodel').updateOne(
{ UserId: parseInt(userId) },
{ $unset: unsetData }
),
req.db.collection('ReadModel-UserReadModel').updateOne(
{ UserId: parseInt(userId) },
{
$unset: unsetData,
$inc: { Version: 1 } // увеличиваем версию, чтобы сбросить кэш Query Server
}
)
]);
console.log(`Removed verification for user ${userId} (Version: ${currentVersion} -> ${currentVersion + 1})`);
// Удаляем из BotVerificationReadModel
await req.db.collection('ReadModel-BotVerificationReadModel').deleteOne({
TargetType: 1,
TargetId: parseInt(userId)
});
res.json({
success: true,
message: 'Verification removed successfully'
});
} catch (error) {
console.error('Error removing user verification:', error);
res.status(500).json({
success: false,
error: 'Failed to remove verification'
});
}
});
// POST /api/verification/upload-icon - загрузка файла иконки TGS
router.post('/upload-icon', upload.single('icon'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'No file uploaded'
});
}
const filePath = req.file.path;
const ext = path.extname(req.file.originalname).toLowerCase();
if (ext !== '.tgs') {
await fs.unlink(filePath);
return res.status(400).json({
success: false,
error: 'Only .tgs files are allowed'
});
}
// Генерируем уникальный ID документа для этого эмодзи
const documentId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
// Загружаем в MinIO (как для эмодзи-паков)
await minioHelper.uploadFile(filePath, documentId, 'application/x-tgsticker');
// Создаём DocumentReadModel (та же структура, что и у эмодзи-паков)
const fileReference = Buffer.alloc(8);
fileReference.writeBigInt64LE(BigInt(documentId));
const documentReadModel = {
_id: `document-${documentId}`,
Id: `document-${documentId}`,
Version: 1,
DocumentId: documentId,
AccessHash: documentId + 100,
FileReference: fileReference,
DcId: 2,
Date: Math.floor(Date.now() / 1000),
MimeType: 'application/x-tgsticker',
Size: req.file.size,
Name: String(documentId),
Attributes2: [
{
_t: 'TDocumentAttributeCustomEmoji',
Free: true,
TextColor: false,
Alt: '🏆',
Stickerset: {
_t: 'TInputStickerSetEmpty'
}
}
]
};
// Используем правильное имя коллекции (соглашение об именовании EventFlow)
const collectionName = 'ReadModel-DocumentReadModel';
const insertResult = await req.db
.collection(collectionName)
.insertOne(documentReadModel);
console.log(`DocumentReadModel created in ${collectionName}: documentId=${documentId}, insertedId=${insertResult.insertedId}`);
// Убеждаемся, что документ создан
const verifyDoc = await req.db
.collection(collectionName)
.findOne({ DocumentId: documentId });
if (!verifyDoc) {
console.error(`Document not found after insert: documentId=${documentId}`);
throw new Error('Document verification failed');
}
console.log(`Document verified in MongoDB: documentId=${documentId}`);
// Удаляем загруженный файл
await fs.unlink(filePath);
res.json({
success: true,
data: {
documentId,
size: req.file.size
}
});
} catch (error) {
console.error('Error uploading icon:', error);
if (req.file) {
await fs.unlink(req.file.path).catch(() => {});
}
res.status(500).json({
success: false,
error: 'Failed to upload icon'
});
}
});
// GET /api/verification/stats - статистика верификации
router.get('/stats', async (req, res) => {
try {
const [totalVerifiers, activeVerifiers, totalVerifications, userVerifications] = await Promise.all([
req.db.collection('ReadModel-BotVerifierReadModel').countDocuments({}),
req.db.collection('ReadModel-BotVerifierReadModel').countDocuments({ IsActive: true }),
req.db.collection('ReadModel-BotVerificationReadModel').countDocuments({}),
req.db.collection('ReadModel-BotVerificationReadModel').countDocuments({ TargetType: 1 })
]);
res.json({
success: true,
data: {
totalVerifiers,
activeVerifiers,
totalVerifications,
userVerifications,
channelVerifications: totalVerifications - userVerifications
}
});
} catch (error) {
console.error('Error fetching verification stats:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch statistics'
});
}
});
// GET /api/verification/icons/:documentId/check - проверить наличие документа иконки
router.get('/icons/:documentId/check', async (req, res) => {
try {
const documentId = parseInt(req.params.documentId);
// Проверяем в MongoDB
const document = await req.db
.collection('ReadModel-DocumentReadModel')
.findOne({ DocumentId: documentId });
// Проверяем в MinIO
const fileExistsInMinio = await minioHelper.fileExists(documentId);
res.json({
success: true,
data: {
documentId,
existsInMongoDB: !!document,
existsInMinIO: fileExistsInMinio,
document: document ? {
DocumentId: document.DocumentId,
Size: document.Size,
MimeType: document.MimeType,
DcId: document.DcId
} : null
}
});
} catch (error) {
console.error('Error checking icon:', error);
res.status(500).json({
success: false,
error: 'Failed to check icon'
});
}
});
export default router;
+340
View File
@@ -0,0 +1,340 @@
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
// Определяем текущий каталог (в ES-модулях нет __dirname по умолчанию)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Загружаем переменные окружения в первую очередь, до остальных импортов
const envPath = join(__dirname, '..', '.env');
dotenv.config({ path: envPath });
// Выводим в лог, откуда загружен .env, и проверяем ключи MinIO
console.log('Loading .env from:', envPath);
console.log('MINIO_ACCESS_KEY:', process.env.MINIO_ACCESS_KEY || 'NOT SET');
console.log('MINIO_SECRET_KEY:', process.env.MINIO_SECRET_KEY ? '***' + process.env.MINIO_SECRET_KEY.slice(-4) : 'NOT SET');
import express from 'express';
import cors from 'cors';
import { MongoClient } from 'mongodb';
import giftsRouter from './routes/gifts.js';
import attributesRouter from './routes/attributes.js';
import statsRouter from './routes/stats.js';
import emojiPacksRouter from './routes/emojipacks.js';
import stickerPacksRouter from './routes/stickerpacks.js';
import verificationRouter from './routes/verification.js';
import reactionsRouter from './routes/reactions.js';
import usersRouter from './routes/users.js';
import sponsoredMessagesRouter from './routes/sponsoredMessages.js';
import serviceNotificationsRouter from './routes/serviceNotifications.js';
import settingsRouter from './routes/settings.js';
import uploadRouter from './routes/upload.js';
import starsRouter from './routes/stars.js';
import frozenAccountsRouter from './routes/frozenAccounts.js';
import frozenSettingsRouter from './routes/frozenSettings.js';
import minioHelper from './utils/minioHelper.js';
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173'
}));
app.use(express.json({ limit: '500mb' }));
app.use(express.urlencoded({ limit: '500mb', extended: true }));
app.use('/uploads', express.static('uploads'));
// MongoDB Connection
let db;
const mongoClient = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
async function connectDB() {
try {
await mongoClient.connect();
db = mongoClient.db(process.env.DB_NAME || 'tg');
console.log('Connected to MongoDB');
// Создаём индексы
await db.collection('AvailableStarGiftReadModel').createIndex({ GiftId: 1 }, { unique: true });
await db.collection('AvailableStarGiftReadModel').createIndex({ SoldOut: 1, Stars: 1 });
await db.collection('ReadModel-UserReadModel').createIndex({ UserId: 1 });
await db.collection('ReadModel-UserReadModel').createIndex({ UserName: 1 });
await db.collection('ReadModel-UserReadModel').createIndex({ PhoneNumber: 1 });
// Индексы для кастомных эмодзи
await db.collection('custom_emoji_documents').createIndex({ document_id: 1 }, { unique: true });
await db.collection('custom_emoji_documents').createIndex({ 'attributes.stickerset_id': 1 });
await db.collection('custom_emoji_documents').createIndex({ 'attributes.alt': 1 });
// Коллекции read-моделей EventFlow
await db.collection('ReadModel-StickerSetReadModel').createIndex({ StickerSetId: 1 }, { unique: true });
await db.collection('ReadModel-StickerSetReadModel').createIndex({ ShortName: 1 }, { unique: true });
await db.collection('ReadModel-DocumentReadModel').createIndex({ DocumentId: 1 }, { unique: true });
await db.collection('ReadModel-DocumentReadModel').createIndex({ _id: 1 });
await db.collection('emoji_packs').createIndex({ stickerset_id: 1, emoticon: 1 }, { unique: true });
// Индексы для рекламных сообщений
await db.collection('ReadModel-SponsoredMessageReadModel').createIndex({ Id: 1 }, { unique: true });
await db.collection('ReadModel-SponsoredMessageReadModel').createIndex({ ChannelId: 1, IsActive: 1 });
await db.collection('ReadModel-SponsoredMessageReadModel').createIndex({ CreatedDate: -1 });
console.log('Database indexes created');
// Инициализируем bucket в MinIO
try {
await minioHelper.ensureBucket();
console.log('MinIO storage initialized');
} catch (error) {
console.warn('MinIO initialization failed (will retry on upload):', error.message);
}
} catch (error) {
console.error('MongoDB connection error:', error);
process.exit(1);
}
}
// Прокидываем подключение к БД во все маршруты
app.use((req, res, next) => {
req.db = db;
next();
});
// Routes
app.use('/api/gifts', giftsRouter);
app.use('/api/attributes', attributesRouter);
app.use('/api/stats', statsRouter);
app.use('/api/emojipacks', emojiPacksRouter);
app.use('/api/stickerpacks', stickerPacksRouter);
app.use('/api/verification', verificationRouter);
app.use('/api/reactions', reactionsRouter);
app.use('/api/users', usersRouter);
app.use('/api/sponsored-messages', sponsoredMessagesRouter);
app.use('/api/service-notifications', serviceNotificationsRouter);
app.use('/api/users/settings', settingsRouter);
app.use('/api/upload', uploadRouter);
app.use('/api/stars', starsRouter);
app.use('/api/frozen-accounts', frozenAccountsRouter);
app.use('/api/frozen-settings', frozenSettingsRouter);
// Быстрый поиск пользователя (устаревший вариант, перенесён в роутер users)
app.get('/api/users-legacy/search', async (req, res) => {
try {
const { phone, username, limit = 10 } = req.query;
const filter = {};
if (phone) filter.PhoneNumber = phone;
if (username) filter.UserName = { $regex: username, $options: 'i' };
const users = await db
.collection('ReadModel-UserReadModel')
.find(filter)
.limit(parseInt(limit))
.project({ UserId: 1, PhoneNumber: 1, UserName: 1, FirstName: 1, LastName: 1 })
.toArray();
res.json(users);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Список всех пользователей с постраничной разбивкой
app.get('/api/users', async (req, res) => {
try {
const { page = 1, limit = 50, search = '' } = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
// Формируем фильтр поиска
const filter = {};
if (search) {
filter.$or = [
{ FirstName: { $regex: search, $options: 'i' } },
{ LastName: { $regex: search, $options: 'i' } },
{ UserName: { $regex: search, $options: 'i' } },
{ PhoneNumber: { $regex: search, $options: 'i' } }
];
}
const [users, total] = await Promise.all([
db.collection('ReadModel-UserReadModel')
.find(filter)
.sort({ UserId: -1 }) // Сначала новые
.skip(skip)
.limit(parseInt(limit))
.project({
UserId: 1,
PhoneNumber: 1,
UserName: 1,
FirstName: 1,
LastName: 1,
AccessHash: 1,
IsDeleted: 1,
Bot: 1
})
.toArray(),
db.collection('ReadModel-UserReadModel').countDocuments(filter)
]);
res.json({
users,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / parseInt(limit))
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Список всех каналов
app.get('/api/channels', async (req, res) => {
try {
const channels = await db.collection('eventflow-channelreadmodel')
.find({ IsDeleted: { $ne: true } })
.sort({ ChannelId: -1 })
.project({
ChannelId: 1,
Title: 1,
UserName: 1,
ParticipantsCount: 1,
AccessHash: 1,
Broadcast: 1,
MegaGroup: 1
})
.toArray();
// Переименовываем ParticipantsCount в MembersCount для совместимости с фронтендом
const formattedChannels = channels.map(ch => ({
...ch,
MembersCount: ch.ParticipantsCount || 0
}));
res.json({
success: true,
channels: formattedChannels
});
} catch (error) {
console.error('Error fetching channels:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Проверка работоспособности сервиса
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
mongodb: db ? 'connected' : 'disconnected',
timestamp: new Date().toISOString()
});
});
// Эндпоинт для проверки соединения с MinIO
app.get('/api/test/minio', async (req, res) => {
try {
const { Client } = await import('minio');
// Берём конфигурацию из переменных окружения
const config = {
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: false,
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin'
};
const testClient = new Client(config);
const bucketName = 'tg-files';
// Проверяем соединение, запросив список bucket'ов
const startTime = Date.now();
let buckets;
try {
buckets = await testClient.listBuckets();
} catch (listError) {
throw new Error(`Failed to list buckets: ${listError.message}`);
}
const listTime = Date.now() - startTime;
// Проверяем, существует ли нужный нам bucket
const bucketExists = buckets.some(b => b.name === bucketName);
// Если bucket'а нет, пробуем его создать
let bucketCreated = false;
if (!bucketExists) {
try {
await testClient.makeBucket(bucketName, 'us-east-1');
bucketCreated = true;
} catch (createError) {
// Не критично, просто отражаем это в ответе
}
}
res.json({
success: true,
config: {
endPoint: config.endPoint,
port: config.port,
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: '***' + config.secretKey.slice(-4)
},
connection: {
status: 'connected',
responseTime: `${listTime}ms`
},
buckets: buckets.map(b => ({
name: b.name,
creationDate: b.creationDate
})),
targetBucket: {
name: bucketName,
exists: bucketExists,
created: bucketCreated
},
timestamp: new Date().toISOString()
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message,
stack: error.stack,
config: {
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: process.env.MINIO_PORT || '9000',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: '***' + (process.env.MINIO_SECRET_KEY || 'minioadmin').slice(-4)
},
timestamp: new Date().toISOString()
});
}
});
// Обработка ошибок
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(500).json({
error: 'Internal server error',
message: err.message
});
});
// Запуск сервера
connectDB().then(() => {
app.listen(PORT, () => {
console.log(`Star Gift Admin API running on http://localhost:${PORT}`);
});
});
// Корректное завершение работы
process.on('SIGINT', async () => {
await mongoClient.close();
process.exit(0);
});
@@ -0,0 +1,156 @@
import { Client } from 'minio';
import fs from 'fs/promises';
import path from 'path';
const BUCKET_NAME = 'tg-files';
// Ленивая инициализация: создаём клиента только при первом обращении
let minioClient = null;
let minioConfig = null;
function getMinioClient() {
if (!minioClient) {
// Конфигурация MinIO (должна совпадать с docker-compose.yml)
minioConfig = {
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: false,
accessKey: process.env.MINIO_ACCESS_KEY || 'test', // совпадает с .env Docker
secretKey: process.env.MINIO_SECRET_KEY || '12345678' // совпадает с .env Docker
};
// При первом использовании выводим конфигурацию (секретный ключ скрываем)
console.log('MinIO Configuration:', {
endPoint: minioConfig.endPoint,
port: minioConfig.port,
useSSL: minioConfig.useSSL,
accessKey: minioConfig.accessKey,
secretKey: '***' + minioConfig.secretKey.slice(-4) // показываем только последние 4 символа
});
minioClient = new Client(minioConfig);
}
return minioClient;
}
/**
* Проверяет наличие bucket'а и создаёт его при отсутствии
*/
export async function ensureBucket() {
const client = getMinioClient();
try {
console.log(`Checking if bucket "${BUCKET_NAME}" exists...`);
const exists = await client.bucketExists(BUCKET_NAME);
if (!exists) {
console.log(`Bucket "${BUCKET_NAME}" does not exist, creating...`);
await client.makeBucket(BUCKET_NAME, 'us-east-1');
console.log(`Created MinIO bucket: ${BUCKET_NAME}`);
} else {
console.log(`Bucket "${BUCKET_NAME}" already exists`);
}
} catch (error) {
console.error('Failed to ensure bucket:', {
message: error.message,
code: error.code,
statusCode: error.statusCode,
endpoint: minioConfig.endPoint + ':' + minioConfig.port,
bucket: BUCKET_NAME
});
throw error;
}
}
/**
* Загружает файл в MinIO
* @param {string} localFilePath - путь к локальному файлу
* @param {number} documentId - ID документа, используется как имя объекта
* @param {string} mimeType - MIME-тип файла
* @returns {Promise<string>} имя объекта в MinIO
*/
export async function uploadFile(localFilePath, documentId, mimeType) {
const client = getMinioClient();
try {
// Убеждаемся, что bucket существует
await ensureBucket();
// file-server ожидает файлы в корне без расширения.
// Сохраняем под именем {documentId} (без папки и расширения) —
// именно так их ищет file-server из MyTelegram
const objectName = `${documentId}`;
// Получаем сведения о файле
const stats = await fs.stat(localFilePath);
// Загружаем в MinIO
const metaData = {
'Content-Type': mimeType,
'document-id': documentId.toString()
};
await client.fPutObject(
BUCKET_NAME,
objectName,
localFilePath,
metaData
);
console.log(`Uploaded to MinIO: ${objectName} (${stats.size} bytes)`);
return objectName;
} catch (error) {
console.error('Failed to upload file to MinIO:', error);
throw error;
}
}
/**
* Удаляет файл из MinIO
* @param {number} documentId - ID документа
*/
export async function deleteFile(documentId) {
const client = getMinioClient();
try {
// Удаляем из корня без расширения
const objectName = `${documentId}`;
try {
await client.removeObject(BUCKET_NAME, objectName);
console.log(`Deleted from MinIO: ${objectName}`);
} catch (err) {
console.warn(`File not found in MinIO: ${objectName}`);
}
} catch (error) {
console.error('Failed to delete file from MinIO:', error);
// Не пробрасываем ошибку — файла может не быть
}
}
/**
* Проверяет, существует ли файл в MinIO
* @param {number} documentId - ID документа
* @returns {Promise<boolean>}
*/
export async function fileExists(documentId) {
const client = getMinioClient();
try {
// Проверяем в корне без расширения
const objectName = `${documentId}`;
try {
await client.statObject(BUCKET_NAME, objectName);
return true;
} catch (err) {
return false;
}
} catch (error) {
return false;
}
}
export default {
uploadFile,
deleteFile,
fileExists,
ensureBucket
};
@@ -0,0 +1,39 @@
import crypto from 'crypto';
/**
* Generate FileReference (24 bytes)
* Format: [4 bytes timestamp][20 bytes random]
*/
export function generateFileReference() {
const buffer = Buffer.allocUnsafe(24);
// First 4 bytes: current UNIX timestamp
buffer.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
// Remaining 20 bytes: cryptographically secure random
const randomBytes = crypto.randomBytes(20);
randomBytes.copy(buffer, 4);
return buffer;
}
/**
* Generate AccessHash (int64)
* Must be cryptographically random
*/
export function generateAccessHash() {
// Generate 8 random bytes
const buffer = crypto.randomBytes(8);
// Convert to signed int64 string for MongoDB
return buffer.readBigInt64BE(0).toString();
}
/**
* Generate DocumentId using timestamp + random
* Ensures uniqueness without DB roundtrip
*/
export function generateDocumentId() {
// Timestamp in milliseconds * 10000 + random component
return Date.now() * 10000 + Math.floor(Math.random() * 10000);
}
@@ -0,0 +1,167 @@
import { MongoClient } from 'mongodb';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
// Определяем текущий каталог (в ES-модулях нет __dirname по умолчанию)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Загружаем переменные окружения
const envPath = join(__dirname, '..', '..', '.env');
dotenv.config({ path: envPath });
const mongoUrl = process.env.MONGODB_URI || 'mongodb://localhost:27017';
const dbName = process.env.DB_NAME || 'tg';
const adminApiUrl = process.env.ADMIN_API_URL || 'http://localhost:5555';
let db;
let isProcessing = false;
// Подключаемся к MongoDB
async function connectDB() {
try {
const client = await MongoClient.connect(mongoUrl);
db = client.db(dbName);
console.log('Service Notification Worker connected to MongoDB');
return client;
} catch (error) {
console.error('MongoDB connection error:', error);
process.exit(1);
}
}
// Отправляем уведомление через HTTP API
async function sendNotificationViaApi(event) {
const payload = {
userId: event.userId,
type: event.type,
message: event.message,
popup: event.popup,
mediaUrl: event.mediaUrl,
mediaType: event.mediaType
};
console.log(` Calling Admin API: ${adminApiUrl}/api/service-notifications/send`);
console.log(` Payload:`, JSON.stringify(payload, null, 2));
const response = await fetch(`${adminApiUrl}/api/service-notifications/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
console.log(` Response status: ${response.status}`);
if (!response.ok) {
const errorText = await response.text();
console.error(` API Error: ${errorText}`);
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const result = await response.json();
console.log(` API Response:`, result);
return result;
}
// Обрабатываем необработанные события уведомлений
async function processPendingNotifications() {
if (isProcessing) {
return; // обработка уже идёт
}
try {
isProcessing = true;
const eventsCollection = db.collection('ServiceNotificationEvents');
// Берём необработанные события (не более 10 за раз)
const pendingEvents = await eventsCollection
.find({ processed: false })
.sort({ createdAt: 1 })
.limit(10)
.toArray();
if (pendingEvents.length === 0) {
return; // необработанных событий нет
}
console.log(`\nProcessing ${pendingEvents.length} pending notifications...`);
for (const event of pendingEvents) {
try {
console.log(` Sending notification to user ${event.userId} (type: ${event.type})`);
// Отправляем через HTTP API
await sendNotificationViaApi(event);
// Помечаем как обработанное
await eventsCollection.updateOne(
{ _id: event._id },
{
$set: {
processed: true,
processedAt: new Date(),
status: 'sent'
}
}
);
console.log(` Notification sent to user ${event.userId}`);
} catch (error) {
console.error(` Failed to send notification to user ${event.userId}:`, error.message);
// Помечаем как неудачное
await eventsCollection.updateOne(
{ _id: event._id },
{
$set: {
processed: true,
processedAt: new Date(),
status: 'failed',
error: error.message
}
}
);
}
}
console.log(`Processed ${pendingEvents.length} notifications\n`);
} catch (error) {
console.error('Error processing notifications:', error);
} finally {
isProcessing = false;
}
}
// Главный цикл воркера
async function startWorker() {
console.log('Service Notification Worker started');
console.log(`Using Admin API: ${adminApiUrl}`);
console.log('Checking for pending notifications every 5 seconds...\n');
await connectDB();
// Сразу обрабатываем события при запуске
await processPendingNotifications();
// Затем проверяем каждые 5 секунд
setInterval(async () => {
await processPendingNotifications();
}, 5000); // проверка каждые 5 секунд
}
// Корректное завершение работы
process.on('SIGINT', () => {
console.log('\nShutting down worker...');
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\nShutting down worker...');
process.exit(0);
});
// Запускаем воркер
startWorker().catch(console.error);