Update files

This commit is contained in:
glpshchn 2025-12-15 03:37:34 +03:00
parent 3be93d1353
commit bab6b49585
12 changed files with 465 additions and 155 deletions

View File

@ -279,29 +279,21 @@ router.put('/posts/:id', authenticateModerationFlexible, requireModerationAccess
return res.status(404).json({ error: 'Пост не найден' });
}
// Проверить, может ли админ редактировать этот пост
// Админ может редактировать:
// 1. Любой пост, если он владелец (req.isOwner)
// 2. Только свои посты из канала (где adminNumber совпадает)
if (!req.isOwner) {
// Получить админа текущего пользователя
const admin = await ModerationAdmin.findOne({ telegramId: req.user.telegramId });
// Если это пост из канала, проверить, что админ - автор
if (post.publishedToChannel && post.adminNumber) {
if (!admin || admin.adminNumber !== post.adminNumber) {
return res.status(403).json({ error: 'Вы можете редактировать только свои посты из канала' });
}
}
// Если это обычный пост, владелец может редактировать любой, остальные админы - нет
// (это поведение можно изменить по необходимости)
}
// Модераторы и админы могут редактировать любой пост через панель модерации
// Доступ к панели модерации уже проверен через requireModerationAccess
if (content !== undefined) {
post.content = content;
post.hashtags = Array.isArray(hashtags)
? hashtags.map((tag) => tag.toLowerCase())
: post.hashtags;
// Обновить хэштеги из контента, если hashtags не переданы явно
if (hashtags !== undefined) {
post.hashtags = Array.isArray(hashtags)
? hashtags.map((tag) => tag.toLowerCase())
: post.hashtags;
} else {
// Извлечь хэштеги из контента
const { extractHashtags } = require('../utils/hashtags');
post.hashtags = extractHashtags(content);
}
}
if (tags !== undefined) {

View File

@ -61,9 +61,9 @@ router.post('/send-code', codeLimiter, async (req, res) => {
const userByEmail = await User.findOne({ email: emailLower });
if (userByEmail) {
console.log(`[ModerationAuth] Пользователь найден, но роль не moderator/admin:`, userByEmail.role);
return res.status(403).json({
error: 'Регистрация недоступна. Обратитесь к администратору для получения доступа.'
});
return res.status(403).json({
error: 'Регистрация недоступна. Обратитесь к администратору для получения доступа.'
});
}
// Если пользователя нет вообще - разрешить отправку кода

View File

@ -366,35 +366,46 @@ router.put('/:id', authenticate, async (req, res) => {
return res.status(403).json({ error: 'Нет прав на редактирование' });
}
// Валидация контента
if (content !== undefined && !validatePostContent(content)) {
logSecurityEvent('INVALID_POST_CONTENT', req);
return res.status(400).json({ error: 'Недопустимый контент поста' });
// Валидация контента (разрешаем пустой контент, если есть изображения)
if (content !== undefined) {
// Если контент не пустой, валидируем его
if (content && content.trim().length > 0) {
if (!validatePostContent(content)) {
logSecurityEvent('INVALID_POST_CONTENT', req);
return res.status(400).json({ error: 'Недопустимый контент поста' });
}
post.content = content.trim();
// Обновить хэштеги
post.hashtags = extractHashtags(content);
} else {
// Пустой контент разрешен, если есть изображения
if (!post.images || post.images.length === 0) {
return res.status(400).json({ error: 'Пост должен содержать текст или изображение' });
}
post.content = '';
post.hashtags = [];
}
}
// Валидация тегов
if (tags) {
if (tags !== undefined) {
let parsedTags = [];
try {
parsedTags = JSON.parse(tags);
} catch (e) {
return res.status(400).json({ error: 'Неверный формат тегов' });
}
if (!validateTags(parsedTags)) {
logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags });
return res.status(400).json({ error: 'Недопустимые теги' });
if (tags) {
try {
parsedTags = typeof tags === 'string' ? JSON.parse(tags) : tags;
} catch (e) {
return res.status(400).json({ error: 'Неверный формат тегов' });
}
if (!validateTags(parsedTags)) {
logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags });
return res.status(400).json({ error: 'Недопустимые теги' });
}
}
post.tags = parsedTags;
}
if (content !== undefined) {
post.content = content;
// Обновить хэштеги
post.hashtags = extractHashtags(content);
}
if (isNSFW !== undefined) {
post.isNSFW = isNSFW === 'true' || isNSFW === true;
}
@ -406,6 +417,10 @@ router.put('/:id', authenticate, async (req, res) => {
res.json({ post });
} catch (error) {
console.error('Ошибка редактирования поста:', error);
// Более детальная обработка ошибок
if (error.name === 'CastError') {
return res.status(400).json({ error: 'Неверный ID поста' });
}
res.status(500).json({ error: 'Ошибка сервера' });
}
});

View File

@ -202,31 +202,31 @@ const sendEmail = async (to, subject, html, text) => {
}
} else {
// Обычный AWS SES
const params = {
Source: fromEmail,
Destination: {
ToAddresses: [to]
const params = {
Source: fromEmail,
Destination: {
ToAddresses: [to]
},
Message: {
Subject: {
Data: subject,
Charset: 'UTF-8'
},
Message: {
Subject: {
Data: subject,
Body: {
Html: {
Data: html,
Charset: 'UTF-8'
},
Body: {
Html: {
Data: html,
Charset: 'UTF-8'
},
Text: {
Data: text || html.replace(/<[^>]*>/g, ''),
Charset: 'UTF-8'
}
Text: {
Data: text || html.replace(/<[^>]*>/g, ''),
Charset: 'UTF-8'
}
}
};
}
};
const result = await sesClient.sendEmail(params).promise();
return { success: true, messageId: result.MessageId };
const result = await sesClient.sendEmail(params).promise();
return { success: true, messageId: result.MessageId };
}
} else if (transporter) {
// Отправка через SMTP (Yandex, Gmail и т.д.)

View File

@ -90,7 +90,7 @@ export default function Feed({ user }) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
} else {
console.warn('[Feed] Элемент поста не найден после загрузки:', postId)
}
}
}, 300)
} catch (error) {
console.error('[Feed] Ошибка загрузки поста:', error)

View File

@ -80,8 +80,22 @@ app.include_router(mod_app_router, prefix="/api/mod-app", tags=["moderation"])
app.include_router(moderation_auth_router, prefix="/api/moderation-auth", tags=["auth"])
# Mount Socket.IO app for WebSocket
socketio_app = get_socketio_app()
app.mount("/socket.io", socketio_app)
# Socket.IO ASGI app needs to wrap FastAPI app to handle both HTTP and WebSocket
try:
socketio_app = get_socketio_app()
# Socket.IO will handle /socket.io and /mod-chat paths
# We need to mount it so it can intercept WebSocket connections
# But FastAPI routes should still work
# The Socket.IO ASGI app will handle its own paths and pass others to FastAPI
app.mount("/socket.io", socketio_app)
app.mount("/mod-chat", socketio_app) # Also mount at /mod-chat for namespace
print("✅ WebSocket (Socket.IO) настроен")
print(" 📡 Namespace /mod-chat доступен для чата модераторов")
except Exception as e:
print(f"⚠️ Ошибка настройки WebSocket: {e}")
import traceback
traceback.print_exc()
print(" Чат модераторов будет недоступен")
if __name__ == "__main__":

View File

@ -131,6 +131,7 @@ class AdminResponse(AdminBase):
# Request models
class UpdatePostRequest(BaseModel):
content: Optional[str] = None
hashtags: Optional[List[str]] = None
tags: Optional[List[str]] = None
isNSFW: Optional[bool] = None
isHomo: Optional[bool] = None

View File

@ -318,12 +318,25 @@ async def update_post(
detail="Пост не найден"
)
# Модераторы и админы могут редактировать любой пост через панель модерации
# Доступ к панели модерации уже проверен через require_moderator
update_data = {}
if request.content is not None:
update_data['content'] = request.content
# Обновить хэштеги из контента, если hashtags не переданы явно
if request.hashtags is not None:
update_data['hashtags'] = [tag.replace('#', '').lower() if tag.startswith('#') else tag.lower() for tag in request.hashtags]
else:
# Извлечь хэштеги из контента (поддержка кириллицы)
import re
hashtags = re.findall(r'#([\wа-яА-ЯёЁ]+)', request.content)
update_data['hashtags'] = [tag.lower() for tag in hashtags]
if request.tags is not None:
update_data['tags'] = [t.lower() for t in request.tags]
if request.isNSFW is not None:
update_data['isNSFW'] = request.isNSFW
if request.isHomo is not None:
@ -338,12 +351,42 @@ async def update_post(
{'$set': update_data}
)
return {"success": True}
# Get updated post with author
updated_post = await posts_collection().find_one({'_id': ObjectId(post_id)})
author = None
if updated_post.get('author'):
author_doc = await users_collection().find_one({'_id': updated_post['author']})
if author_doc:
author = {
'id': str(author_doc['_id']),
'username': author_doc.get('username'),
'firstName': author_doc.get('firstName'),
'lastName': author_doc.get('lastName')
}
return {
"post": {
"id": str(updated_post['_id']),
"author": author,
"content": updated_post.get('content'),
"hashtags": updated_post.get('hashtags', []),
"tags": updated_post.get('tags', []),
"images": updated_post.get('images', []) or ([updated_post.get('imageUrl')] if updated_post.get('imageUrl') else []),
"isNSFW": updated_post.get('isNSFW', False),
"isArt": updated_post.get('isArt', False),
"publishedToChannel": updated_post.get('publishedToChannel', False),
"adminNumber": updated_post.get('adminNumber'),
"editedAt": updated_post.get('editedAt').isoformat() if updated_post.get('editedAt') else None,
"createdAt": updated_post.get('createdAt', datetime.utcnow()).isoformat()
}
}
except HTTPException:
raise
except Exception as e:
print(f"[ModApp] Ошибка обновления поста: {e}")
import traceback
traceback.print_exc()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Ошибка сервера"

View File

@ -22,6 +22,7 @@ from utils.auth import (
get_current_user, normalize_username, is_moderation_admin
)
from utils.email_service import send_verification_code
from utils.telegram_widget import validate_telegram_widget
from config import settings
router = APIRouter()
@ -318,6 +319,34 @@ async def telegram_widget_auth(request: TelegramWidgetAuth, response: Response):
print(f"[ModerationAuth] 🔍 Запрос авторизации через Telegram виджет: id={request.id}, username={request.username}")
try:
# Validate Telegram widget data if hash is provided
if request.hash and request.auth_date:
auth_data = {
'id': request.id,
'first_name': request.first_name,
'last_name': request.last_name,
'username': request.username,
'photo_url': request.photo_url,
'auth_date': request.auth_date,
'hash': request.hash
}
try:
is_valid = validate_telegram_widget(auth_data, settings.MODERATION_BOT_TOKEN)
if not is_valid:
print(f"[ModerationAuth] ❌ Неверная подпись Telegram виджета")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверная подпись авторизации"
)
print(f"[ModerationAuth] ✅ Подпись Telegram виджета валидна")
except ValueError as e:
print(f"[ModerationAuth] ❌ Ошибка валидации виджета: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e)
)
# Find user by telegramId
print(f"[ModerationAuth] Поиск пользователя с telegramId={request.id}")
user = await users_collection().find_one({'telegramId': str(request.id)})

View File

@ -1,5 +1,5 @@
"""
Email sending utilities with support for Yandex SMTP
Email sending utilities with support for AWS SES and Yandex SMTP
"""
import smtplib
import logging
@ -11,6 +11,15 @@ from config import settings
logger = logging.getLogger(__name__)
# Try to import boto3 for AWS SES
try:
import boto3
from botocore.exceptions import ClientError, BotoCoreError
BOTO3_AVAILABLE = True
except ImportError:
BOTO3_AVAILABLE = False
logger.warning("[Email] boto3 не установлен. AWS SES недоступен. Установите: pip install boto3")
def generate_verification_email(code: str) -> str:
"""Generate HTML email with verification code"""
@ -43,6 +52,117 @@ def generate_verification_email(code: str) -> str:
"""
async def send_email_aws_ses(to: str, subject: str, html: str, text: Optional[str] = None):
"""Send email via AWS SES or Yandex Cloud Postbox (SESv2)"""
if not BOTO3_AVAILABLE:
raise ValueError("boto3 не установлен. Установите: pip install boto3")
if not settings.AWS_SES_ACCESS_KEY_ID or not settings.AWS_SES_SECRET_ACCESS_KEY:
raise ValueError("AWS_SES_ACCESS_KEY_ID и AWS_SES_SECRET_ACCESS_KEY должны быть установлены в .env")
try:
# Check if using Yandex Cloud Postbox (has custom endpoint)
is_yandex_cloud = bool(settings.AWS_SES_ENDPOINT_URL and 'yandex' in settings.AWS_SES_ENDPOINT_URL.lower())
# Create client config
client_config = {
'aws_access_key_id': settings.AWS_SES_ACCESS_KEY_ID,
'aws_secret_access_key': settings.AWS_SES_SECRET_ACCESS_KEY,
'region_name': settings.AWS_SES_REGION
}
# Add custom endpoint if specified (for Yandex Cloud Postbox)
if settings.AWS_SES_ENDPOINT_URL:
client_config['endpoint_url'] = settings.AWS_SES_ENDPOINT_URL
# Yandex Cloud Postbox uses SESv2 API
# Also check if region is ru-central1 (Yandex Cloud region)
if is_yandex_cloud or settings.AWS_SES_REGION == 'ru-central1':
endpoint_url = settings.AWS_SES_ENDPOINT_URL or 'https://postbox.cloud.yandex.net'
logger.info(f"[Email] Использование Yandex Cloud Postbox (SESv2 API): {endpoint_url}")
# Override endpoint if not set
if not settings.AWS_SES_ENDPOINT_URL:
client_config['endpoint_url'] = endpoint_url
sesv2_client = boto3.client('sesv2', **client_config)
# Prepare email for SESv2
destination = {'ToAddresses': [to]}
content = {
'Simple': {
'Subject': {
'Data': subject,
'Charset': 'UTF-8'
},
'Body': {
'Html': {
'Data': html,
'Charset': 'UTF-8'
},
'Text': {
'Data': text or html.replace('<[^>]*>', ''),
'Charset': 'UTF-8'
}
}
}
}
# Send email via SESv2
response = sesv2_client.send_email(
FromEmailAddress=settings.EMAIL_FROM,
Destination=destination,
Content=content
)
logger.info(f"✅ Email отправлен через Yandex Cloud Postbox на {to}, MessageId: {response.get('MessageId')}")
return {"success": True, "to": to, "messageId": response.get('MessageId')}
else:
# Standard AWS SES (SESv1 API)
logger.info(f"[Email] Использование AWS SES (SESv1 API): {settings.AWS_SES_REGION}")
ses_client = boto3.client('ses', **client_config)
# Prepare email for SESv1
destination = {'ToAddresses': [to]}
message = {
'Subject': {'Data': subject, 'Charset': 'UTF-8'},
'Body': {
'Html': {'Data': html, 'Charset': 'UTF-8'},
'Text': {'Data': text or html.replace('<[^>]*>', ''), 'Charset': 'UTF-8'}
}
}
# Send email
response = ses_client.send_email(
Source=settings.EMAIL_FROM,
Destination=destination,
Message=message
)
logger.info(f"✅ Email отправлен через AWS SES на {to}, MessageId: {response.get('MessageId')}")
return {"success": True, "to": to, "messageId": response.get('MessageId')}
except ClientError as e:
error_code = e.response.get('Error', {}).get('Code', 'Unknown')
error_msg = e.response.get('Error', {}).get('Message', str(e))
logger.error(f"❌ AWS SES ошибка ({error_code}): {error_msg}")
# More informative error messages
if error_code == 'MessageRejected':
raise ValueError(f"Письмо отклонено: {error_msg}. Проверьте, что адрес {settings.EMAIL_FROM} верифицирован.")
elif error_code == 'InvalidParameterValue':
raise ValueError(f"Неверный параметр: {error_msg}")
elif error_code == 'AccessDenied':
raise ValueError(f"Доступ запрещен: {error_msg}. Проверьте права доступа ключей.")
raise ValueError(f"AWS SES ошибка ({error_code}): {error_msg}")
except Exception as e:
logger.error(f"❌ Ошибка отправки email через AWS SES: {e}")
import traceback
traceback.print_exc()
raise
async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str] = None):
"""Send email via SMTP (Yandex, Gmail, etc.)"""
try:
@ -81,7 +201,7 @@ async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str]
logger.info(f"✅ Email отправлен на {to}")
return {"success": True, "to": to}
else:
raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'yandex' или 'smtp'")
raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'aws', 'yandex' или 'smtp'")
except Exception as e:
logger.error(f"❌ Ошибка отправки email: {e}")
@ -100,13 +220,25 @@ async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str]
raise
async def send_email(to: str, subject: str, html: str, text: Optional[str] = None):
"""Send email using configured provider (AWS SES or SMTP)"""
email_provider = settings.EMAIL_PROVIDER.lower()
if email_provider == 'aws':
return await send_email_aws_ses(to, subject, html, text)
elif email_provider in ['yandex', 'smtp']:
return await send_email_smtp(to, subject, html, text)
else:
raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'aws', 'yandex' или 'smtp'")
async def send_verification_code(email: str, code: str):
"""Send verification code to email"""
subject = "Код подтверждения регистрации - Nakama"
html = generate_verification_email(code)
text = f"Ваш код подтверждения: {code}. Код действителен 15 минут."
return await send_email_smtp(email, subject, html, text)
return await send_email(email, subject, html, text)
async def send_admin_confirmation_code(code: str, action: str, user_info: dict):
@ -152,5 +284,5 @@ async def send_admin_confirmation_code(code: str, action: str, user_info: dict):
Пользователь: @{user_info.get('username', 'не указан')}
Код действителен 5 минут."""
return await send_email_smtp(settings.OWNER_EMAIL, subject, html, text)
return await send_email(settings.OWNER_EMAIL, subject, html, text)

View File

@ -0,0 +1,68 @@
"""
Telegram Login Widget validation
"""
import hmac
import hashlib
from typing import Optional, Dict, Any
from config import settings
def validate_telegram_widget(auth_data: Dict[str, Any], bot_token: Optional[str] = None) -> bool:
"""
Validate Telegram Login Widget authentication data
Args:
auth_data: Dictionary with user data and hash from Telegram widget
bot_token: Bot token (uses MODERATION_BOT_TOKEN if not provided)
Returns:
True if validation successful
Raises:
ValueError: If validation fails
"""
token_to_use = bot_token or settings.MODERATION_BOT_TOKEN
if not token_to_use or not isinstance(token_to_use, str) or not token_to_use.strip():
raise ValueError('Bot token модерации не настроен (MODERATION_BOT_TOKEN)')
if not auth_data or 'hash' not in auth_data:
raise ValueError('Отсутствует hash в authData')
received_hash = auth_data.pop('hash')
# Clean data - remove None, empty strings, and convert all to strings
clean_data = {}
for key, value in auth_data.items():
if value is not None and value != '':
clean_data[key] = str(value)
# Create data check string (sorted keys)
data_check_arr = sorted(clean_data.items())
data_check_string = '\n'.join(f'{key}={value}' for key, value in data_check_arr)
# Create secret key
secret_key = hmac.new(
'WebAppData'.encode('utf-8'),
token_to_use.encode('utf-8'),
hashlib.sha256
).digest()
# Calculate hash
calculated_hash = hmac.new(
secret_key,
data_check_string.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare hashes
is_valid = calculated_hash == received_hash
if not is_valid:
print(f"[TelegramWidget] Hash mismatch:")
print(f" Calculated: {calculated_hash[:20]}...")
print(f" Received: {received_hash[:20]}...")
print(f" Data check string: {data_check_string}")
return is_valid

View File

@ -3,7 +3,12 @@ WebSocket server for moderation chat
"""
import socketio
import logging
from typing import Dict, Set
from typing import Dict, Set, Optional
from datetime import datetime
from config import settings
from database import moderation_admins_collection
from utils.auth import normalize_username
logger = logging.getLogger(__name__)
@ -16,109 +21,120 @@ sio = socketio.AsyncServer(
)
# Track connected moderators
connected_moderators: Dict[str, Set[str]] = {} # {room: {sid1, sid2, ...}}
connected_moderators: Dict[str, dict] = {} # {sid: {username, telegramId, isOwner}}
@sio.event
async def connect(sid, environ):
"""Handle client connection"""
logger.info(f"[WebSocket] Client connected: {sid}")
@sio.event
async def disconnect(sid):
"""Handle client disconnection"""
logger.info(f"[WebSocket] Client disconnected: {sid}")
# Remove from all rooms
for room, sids in connected_moderators.items():
if sid in sids:
sids.remove(sid)
await sio.emit('user_left', {'sid': sid}, room=room, skip_sid=sid)
@sio.event
async def join_moderation_chat(sid, data):
"""Join moderation chat room"""
try:
user_id = data.get('userId')
def broadcast_online():
"""Broadcast list of online moderators"""
unique = {}
for data in connected_moderators.values():
username = data.get('username')
if username:
unique[username] = data
online_list = list(unique.values())
sio.emit('online', online_list, namespace='/mod-chat')
# Create namespace for moderation chat
mod_chat = sio.namespace('/mod-chat')
@mod_chat.on('connect')
async def on_connect(sid, environ):
"""Handle client connection to /mod-chat namespace"""
logger.info(f"[WebSocket] Client connected to /mod-chat: {sid}")
# Don't authorize immediately - wait for 'auth' event
@mod_chat.on('disconnect')
async def on_disconnect(sid):
"""Handle client disconnection"""
logger.info(f"[WebSocket] Client disconnected from /mod-chat: {sid}")
if sid in connected_moderators:
del connected_moderators[sid]
broadcast_online()
@mod_chat.on('auth')
async def on_auth(sid, data):
"""Handle authentication for moderation chat"""
try:
username = normalize_username(data.get('username')) if data.get('username') else None
telegram_id = data.get('telegramId')
if not user_id:
if not username or not telegram_id:
logger.warning(f"[WebSocket] Auth failed: missing username or telegramId")
await sio.emit('unauthorized', namespace='/mod-chat', room=sid)
await sio.disconnect(sid, namespace='/mod-chat')
return
room = 'moderation_chat'
await sio.enter_room(sid, room)
# Check if user is owner
owner_usernames = settings.OWNER_USERNAMES_LIST
is_owner = username.lower() in [u.lower() for u in owner_usernames]
if room not in connected_moderators:
connected_moderators[room] = set()
connected_moderators[room].add(sid)
# Check if user is moderation admin
admin = await moderation_admins_collection().find_one({
'telegramId': str(telegram_id)
})
is_admin = admin is not None
logger.info(f"[WebSocket] {username} ({user_id}) joined moderation chat")
if not is_owner and not is_admin:
logger.warning(f"[WebSocket] Access denied: {username} (telegramId: {telegram_id})")
await sio.emit('unauthorized', namespace='/mod-chat', room=sid)
await sio.disconnect(sid, namespace='/mod-chat')
return
# Notify others
await sio.emit('user_joined', {
'userId': user_id,
# Store connection data
connected_moderators[sid] = {
'username': username,
'sid': sid
}, room=room, skip_sid=sid)
except Exception as e:
logger.error(f"[WebSocket] Error joining chat: {e}")
@sio.event
async def leave_moderation_chat(sid, data):
"""Leave moderation chat room"""
try:
room = 'moderation_chat'
await sio.leave_room(sid, room)
if room in connected_moderators and sid in connected_moderators[room]:
connected_moderators[room].remove(sid)
# Notify others
await sio.emit('user_left', {'sid': sid}, room=room, skip_sid=sid)
except Exception as e:
logger.error(f"[WebSocket] Error leaving chat: {e}")
@sio.event
async def moderation_message(sid, data):
"""Handle moderation chat message"""
try:
room = 'moderation_chat'
message_data = {
'userId': data.get('userId'),
'username': data.get('username'),
'message': data.get('message'),
'timestamp': data.get('timestamp')
'telegramId': telegram_id,
'isOwner': is_owner
}
# Broadcast to all in room
await sio.emit('moderation_message', message_data, room=room)
logger.info(f"[WebSocket] Message from {data.get('username')}: {data.get('message')[:50]}...")
logger.info(f"[WebSocket] Auth success: {username} (owner: {is_owner}, admin: {is_admin})")
await sio.emit('ready', namespace='/mod-chat', room=sid)
broadcast_online()
except Exception as e:
logger.error(f"[WebSocket] Error sending message: {e}")
logger.error(f"[WebSocket] Error in auth: {e}")
import traceback
traceback.print_exc()
await sio.emit('unauthorized', namespace='/mod-chat', room=sid)
await sio.disconnect(sid, namespace='/mod-chat')
@sio.event
async def typing(sid, data):
"""Handle typing indicator"""
@mod_chat.on('message')
async def on_message(sid, data):
"""Handle moderation chat message"""
try:
room = 'moderation_chat'
await sio.emit('typing', {
'userId': data.get('userId'),
'username': data.get('username'),
'isTyping': data.get('isTyping', True)
}, room=room, skip_sid=sid)
if sid not in connected_moderators:
logger.warning(f"[WebSocket] Message from unauthorized client: {sid}")
return
user_data = connected_moderators[sid]
text = (data.get('text') or '').strip()
if not text:
return
message = {
'id': f"{int(datetime.utcnow().timestamp() * 1000)}-{sid[:8]}",
'username': user_data['username'],
'telegramId': user_data['telegramId'],
'text': text,
'createdAt': datetime.utcnow().isoformat()
}
# Broadcast to all in namespace
await sio.emit('message', message, namespace='/mod-chat')
logger.info(f"[WebSocket] Message from {user_data['username']}: {text[:50]}...")
except Exception as e:
logger.error(f"[WebSocket] Error handling typing: {e}")
logger.error(f"[WebSocket] Error handling message: {e}")
import traceback
traceback.print_exc()
def get_socketio_app():