Update files

This commit is contained in:
glpshchn 2025-12-04 20:44:05 +03:00
parent e9afda5e16
commit af063ecc7d
12 changed files with 476 additions and 6 deletions

199
backend/bots/mainBot.js Normal file
View File

@ -0,0 +1,199 @@
const axios = require('axios');
const config = require('../config');
const { log, logError } = require('../middleware/logger');
const User = require('../models/User');
const BOT_TOKEN = config.telegramBotToken;
const TELEGRAM_API = BOT_TOKEN ? `https://api.telegram.org/bot${BOT_TOKEN}` : null;
let isPolling = false;
let offset = 0;
const sendMessage = async (chatId, text, options = {}) => {
if (!TELEGRAM_API) {
log('warn', 'TELEGRAM_BOT_TOKEN не установлен, отправка сообщения невозможна');
return null;
}
try {
const response = await axios.post(`${TELEGRAM_API}/sendMessage`, {
chat_id: chatId,
text,
parse_mode: 'HTML',
...options
});
return response.data;
} catch (error) {
logError('Ошибка отправки сообщения', error, { chatId, text: text.substring(0, 50) });
return null;
}
};
const sendMessageToAllUsers = async (messageText) => {
if (!TELEGRAM_API) {
throw new Error('TELEGRAM_BOT_TOKEN не установлен');
}
try {
const users = await User.find({ banned: { $ne: true } }).select('telegramId');
let sent = 0;
let failed = 0;
for (const user of users) {
try {
await sendMessage(user.telegramId, messageText);
sent++;
// Небольшая задержка, чтобы не превысить лимиты API
await new Promise(resolve => setTimeout(resolve, 50));
} catch (error) {
failed++;
logError('Ошибка отправки сообщения пользователю', error, { telegramId: user.telegramId });
}
}
return { sent, failed, total: users.length };
} catch (error) {
logError('Ошибка массовой отправки сообщений', error);
throw error;
}
};
const getStartMessage = () => {
return `👋 <b>Добро пожаловать в Nakama!</b>
📱 <b>Nakama</b> социальная сеть для фурри и аниме сообщества.
<b>Основные возможности:</b>
Создание постов с текстом и изображениями
Поиск контента через e621 и Gelbooru
Комментарии и лайки
Подписки на пользователей
Система уведомлений
Фильтры и теги
<b>Как начать:</b>
1. Нажмите кнопку "Войти" ниже, чтобы запустить приложение
2. Создайте свой первый пост
3. Подписывайтесь на интересных пользователей
<b>Поддержка:</b>
Если возникли проблемы, напишите @NakamaReportbot
Приятного использования!`;
};
const handleCommand = async (message) => {
const chatId = message.chat.id;
const text = (message.text || '').trim();
const args = text.split(/\s+/);
const command = args[0].toLowerCase();
if (command === '/start') {
const startParam = message.text.split(' ')[1] || '';
// Если есть start_param (например, post_12345 или ref_ABC123)
// Это обрабатывается при открытии миниаппа, здесь просто показываем инструкцию
const startMessage = getStartMessage();
// Добавить кнопку для открытия миниаппа
let botUsername = 'NakamaSpaceBot';
if (config.telegramBotToken) {
try {
const botInfo = await axios.get(`${TELEGRAM_API}/getMe`);
botUsername = botInfo.data.result.username || 'NakamaSpaceBot';
} catch (error) {
log('warn', 'Не удалось получить имя бота', { error: error.message });
}
}
await sendMessage(chatId, startMessage, {
reply_markup: {
inline_keyboard: [[
{
text: '🚀 Открыть Nakama',
web_app: {
url: `https://t.me/${botUsername}`
}
}
]]
}
});
return;
}
// Игнорируем неизвестные команды
};
const processUpdate = async (update) => {
const message = update.message || update.edited_message;
if (!message || !message.text) {
return;
}
try {
await handleCommand(message);
} catch (error) {
logError('Ошибка обработки команды основного бота', error, {
chatId: message.chat.id,
text: message.text?.substring(0, 50)
});
}
};
const pollUpdates = async () => {
if (!TELEGRAM_API) {
log('warn', 'Основной бот не запущен: TELEGRAM_BOT_TOKEN не установлен');
return;
}
if (isPolling) {
return;
}
isPolling = true;
log('info', 'Основной бот запущен, опрос обновлений...');
const poll = async () => {
try {
const response = await axios.get(`${TELEGRAM_API}/getUpdates`, {
params: {
offset,
timeout: 30,
allowed_updates: ['message']
}
});
const updates = response.data.result || [];
for (const update of updates) {
offset = update.update_id + 1;
await processUpdate(update);
}
// Продолжить опрос
setTimeout(poll, 1000);
} catch (error) {
logError('Ошибка опроса Telegram для основного бота', error);
// Переподключиться через 5 секунд
setTimeout(poll, 5000);
}
};
poll();
};
const startMainBot = () => {
if (!BOT_TOKEN) {
log('warn', 'Основной бот не запущен: TELEGRAM_BOT_TOKEN не установлен');
return;
}
pollUpdates();
};
module.exports = {
startMainBot,
sendMessageToAllUsers,
sendMessage
};

View File

@ -159,6 +159,7 @@ const authenticate = async (req, res, next) => {
}
const telegramUser = payload.user;
const startParam = payload.start_param || payload.startParam;
if (!validateTelegramId(telegramUser.id)) {
logSecurityEvent('INVALID_TELEGRAM_ID', req, { telegramId: telegramUser.id });
@ -171,14 +172,28 @@ const authenticate = async (req, res, next) => {
let user = await User.findOne({ telegramId: normalizedUser.id.toString() });
if (!user) {
// Обработка реферального кода из start_param
let referredBy = null;
if (startParam && startParam.startsWith('ref_')) {
const referralCode = startParam;
const referrer = await User.findOne({ referralCode });
if (referrer) {
referredBy = referrer._id;
}
}
user = new User({
telegramId: normalizedUser.id.toString(),
username: normalizedUser.username || normalizedUser.firstName || 'user',
firstName: normalizedUser.firstName,
lastName: normalizedUser.lastName,
photoUrl: normalizedUser.photoUrl
photoUrl: normalizedUser.photoUrl,
referredBy: referredBy
});
await user.save();
// Счетчик рефералов увеличивается только когда пользователь создаст первый пост
// (см. routes/posts.js)
} else {
// Обновлять только если есть новые данные, не перезаписывать существующие пустыми значениями
if (normalizedUser.username) {

View File

@ -55,11 +55,35 @@ const UserSchema = new mongoose.Schema({
default: false
},
bannedUntil: Date,
// Реферальная система
referralCode: {
type: String,
unique: true,
sparse: true
},
referredBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
referralsCount: {
type: Number,
default: 0
},
createdAt: {
type: Date,
default: Date.now
}
});
// Генерировать реферальный код перед сохранением
UserSchema.pre('save', async function(next) {
if (!this.referralCode) {
// Генерировать уникальный код на основе telegramId
const code = `ref_${this.telegramId.slice(-8)}${Math.random().toString(36).substring(2, 6)}`.toUpperCase();
this.referralCode = code;
}
next();
});
module.exports = mongoose.model('User', UserSchema);

View File

@ -55,6 +55,8 @@ const respondWithUser = async (user, res) => {
role: populatedUser.role,
followersCount: populatedUser.followers.length,
followingCount: populatedUser.following.length,
referralCode: populatedUser.referralCode,
referralsCount: populatedUser.referralsCount || 0,
settings,
banned: populatedUser.banned
}
@ -232,6 +234,8 @@ router.post('/oauth', strictAuthLimiter, async (req, res) => {
role: populatedUser.role,
followersCount: populatedUser.followers.length,
followingCount: populatedUser.following.length,
referralCode: populatedUser.referralCode,
referralsCount: populatedUser.referralsCount || 0,
settings,
banned: populatedUser.banned
}
@ -277,6 +281,8 @@ router.post('/session', async (req, res) => {
{ path: 'following', select: 'username firstName lastName photoUrl' }
]);
const settings = normalizeUserSettings(populatedUser.settings);
res.json({
success: true,
user: {
@ -290,6 +296,8 @@ router.post('/session', async (req, res) => {
role: populatedUser.role,
followersCount: populatedUser.followers.length,
followingCount: populatedUser.following.length,
referralCode: populatedUser.referralCode,
referralsCount: populatedUser.referralsCount || 0,
settings,
banned: populatedUser.banned
}

View File

@ -57,5 +57,36 @@ router.post('/send-photos', authenticate, async (req, res) => {
}
});
// Отправить сообщение всем пользователям (только админы)
router.post('/broadcast', authenticate, async (req, res) => {
try {
// Проверка прав админа
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Требуются права администратора' });
}
const { message } = req.body;
if (!message || !message.trim()) {
return res.status(400).json({ error: 'Сообщение обязательно' });
}
const { sendMessageToAllUsers } = require('../bots/mainBot');
const result = await sendMessageToAllUsers(message);
res.json({
success: true,
message: `Сообщение отправлено ${result.sent} пользователям`,
result
});
} catch (error) {
console.error('Ошибка рассылки:', error);
res.status(500).json({
error: 'Ошибка отправки сообщений',
details: error.message
});
}
});
module.exports = router;

View File

@ -58,7 +58,8 @@ const serializeUser = (user) => ({
banned: user.banned,
bannedUntil: user.bannedUntil,
lastActiveAt: user.lastActiveAt,
createdAt: user.createdAt
createdAt: user.createdAt,
referralsCount: user.referralsCount || 0
});
router.post('/auth/verify', authenticateModeration, requireModerationAccess, async (req, res) => {

View File

@ -153,6 +153,17 @@ router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploa
await post.save();
await post.populate('author', 'username firstName lastName photoUrl');
// Проверка первого поста для реферальной системы
// Счетчик рефералов увеличивается только когда приглашенный пользователь создал первый пост
const userPostsCount = await Post.countDocuments({ author: req.user._id });
if (userPostsCount === 1 && req.user.referredBy) {
// Это первый пост пользователя, который был приглашен по реферальной ссылке
const User = require('../models/User');
await User.findByIdAndUpdate(req.user.referredBy, {
$inc: { referralsCount: 1 }
});
}
// Создать уведомления для упомянутых пользователей
if (post.mentionedUsers.length > 0) {
const notifications = post.mentionedUsers.map(userId => ({

View File

@ -262,6 +262,8 @@ initWebSocket(server);
// Автообновление аватарок отключено - обновление происходит только при перезаходе
// scheduleAvatarUpdates();
startServerMonitorBot();
const { startMainBot } = require('./bots/mainBot');
startMainBot();
// Обработка необработанных ошибок
process.on('uncaughtException', (error) => {

View File

@ -1,8 +1,8 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Heart, MessageCircle, MoreVertical, ChevronLeft, ChevronRight, Download, Send, X, ZoomIn } from 'lucide-react'
import { Heart, MessageCircle, MoreVertical, ChevronLeft, ChevronRight, Download, Send, X, ZoomIn, Share2 } from 'lucide-react'
import { likePost, deletePost, sendPhotoToTelegram } from '../utils/api'
import { hapticFeedback, showConfirm } from '../utils/telegram'
import { hapticFeedback, showConfirm, openTelegramLink } from '../utils/telegram'
import './PostCard.css'
const TAG_COLORS = {
@ -114,6 +114,27 @@ export default function PostCard({ post, currentUser, onUpdate }) {
}
}
const handleRepost = () => {
try {
hapticFeedback('light')
// Получить имя бота из переменных окружения или использовать дефолтное
const botName = import.meta.env.VITE_TELEGRAM_BOT_NAME || 'NakamaSpaceBot'
// Создать deeplink для открытия поста в миниапп
const deeplink = `https://t.me/${botName}?startapp=post_${post._id}`
// Открыть нативное окно "Поделиться" в Telegram
const shareUrl = `https://t.me/share/url?url=${encodeURIComponent(deeplink)}&text=${encodeURIComponent('Смотри пост в Nakama!')}`
openTelegramLink(shareUrl)
hapticFeedback('success')
} catch (error) {
console.error('Ошибка репоста:', error)
hapticFeedback('error')
}
}
return (
<div className="post-card card fade-in">
{/* Хедер поста */}
@ -236,6 +257,17 @@ export default function PostCard({ post, currentUser, onUpdate }) {
<span>{post.comments.length}</span>
</button>
<button
className="action-btn"
onClick={(e) => {
e.stopPropagation()
handleRepost()
}}
title="Поделиться постом"
>
<Share2 size={20} stroke="currentColor" />
</button>
{images.length > 0 && (
<button
className="action-btn"

View File

@ -182,6 +182,96 @@
opacity: 0.85;
}
/* Реферальная карточка */
.referral-card {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 12px;
}
.referral-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.referral-icon {
width: 36px;
height: 36px;
border-radius: 12px;
background: rgba(52, 199, 89, 0.12);
color: #34C759;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.referral-text h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.referral-text p {
margin: 4px 0 8px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.4;
}
.referral-stats {
font-size: 13px;
color: var(--text-secondary);
}
.referral-stats strong {
color: var(--text-primary);
font-weight: 600;
}
.referral-link-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.referral-link {
padding: 12px;
background: var(--bg-primary);
border-radius: 12px;
border: 1px solid var(--divider-color);
word-break: break-all;
}
.referral-link code {
font-size: 12px;
color: var(--text-primary);
font-family: 'SF Mono', 'Monaco', 'Courier New', monospace;
}
.referral-copy-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
border-radius: 12px;
background: var(--button-accent);
color: white;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
}
.referral-copy-btn:active {
opacity: 0.85;
}
.search-switch {
display: flex;
background: var(--bg-primary);

View File

@ -1,7 +1,7 @@
import { useState } from 'react'
import { Settings, Heart, Edit2, Shield } from 'lucide-react'
import { Settings, Heart, Edit2, Shield, Copy, Users } from 'lucide-react'
import { updateProfile } from '../utils/api'
import { hapticFeedback } from '../utils/telegram'
import { hapticFeedback, showAlert } from '../utils/telegram'
import ThemeToggle from '../components/ThemeToggle'
import './Profile.css'
@ -181,6 +181,62 @@ export default function Profile({ user, setUser }) {
</button>
</div>
{/* Реферальная ссылка */}
{user.referralCode && (
<div className="referral-card card">
<div className="referral-content">
<div className="referral-icon">
<Users size={20} />
</div>
<div className="referral-text">
<h3>Пригласи друзей</h3>
<p>Получи +1 к счетчику, когда приглашенный создаст первый пост</p>
<div className="referral-stats">
Приглашено: <strong>{user.referralsCount || 0}</strong>
</div>
</div>
</div>
<div className="referral-link-section">
<div className="referral-link">
<code>{`https://t.me/${import.meta.env.VITE_TELEGRAM_BOT_NAME || 'NakamaSpaceBot'}?startapp=${user.referralCode}`}</code>
</div>
<button
className="referral-copy-btn"
onClick={async () => {
try {
hapticFeedback('light')
const botName = import.meta.env.VITE_TELEGRAM_BOT_NAME || 'NakamaSpaceBot'
const referralLink = `https://t.me/${botName}?startapp=${user.referralCode}`
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(referralLink)
hapticFeedback('success')
showAlert('✅ Ссылка скопирована!')
} else {
const textArea = document.createElement('textarea')
textArea.value = referralLink
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
hapticFeedback('success')
showAlert('✅ Ссылка скопирована!')
}
} catch (error) {
console.error('Ошибка копирования:', error)
hapticFeedback('error')
}
}}
>
<Copy size={18} />
<span>Копировать</span>
</button>
</div>
</div>
)}
<div className="profile-powered">
Powered by glpshcn \\ RBach \\ E621 \\ GelBooru
</div>

View File

@ -496,6 +496,7 @@ export default function App() {
<div className="list-item-meta">
<span>Роль: {u.role}</span>
<span>Активность: {formatDate(u.lastActiveAt)}</span>
{u.referralsCount > 0 && <span className="badge badge-info">Рефералов: {u.referralsCount}</span>}
{u.banned && <span className="badge badge-danger">Бан до {formatDate(u.bannedUntil)}</span>}
</div>
</div>