Files
2026-09-20 11:45:58 +00:00

359 lines
13 KiB
Python

"""
Support Router — система поддержки пользователей.
Пользователи пишут админу о багах, неточностях и предложениях.
Админ отвечает. Обе стороны могут переводить сообщения через AI (MiniMax-M3).
"""
import logging
import httpx
import re
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
from config import TOGETHER_API_KEY, ADMIN_IDS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/support", tags=["support"])
TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions"
SUPPORT_MODEL = "MiniMaxAI/MiniMax-M3"
async def call_ai_translate(text: str, target_lang: str) -> str:
"""Переводит текст через AI (MiniMax-M3)."""
if not TOGETHER_API_KEY:
return ""
try:
async with httpx.AsyncClient(timeout=90.0) as client:
response = await client.post(
TOGETHER_API_URL,
headers={
"Authorization": f"Bearer {TOGETHER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": SUPPORT_MODEL,
"messages": [
{"role": "system", "content": f"Ты — переводчик. Переведи текст на язык: {target_lang}. Отвечай только переводом без пояснений. Не используй markdown."},
{"role": "user", "content": text},
],
"max_tokens": 500,
"temperature": 0.3,
},
)
if response.status_code != 200:
logger.error(f"Translate API error: {response.status_code}")
return ""
data = response.json()
translated = data["choices"][0]["message"]["content"].strip()
translated = re.sub(r'\s*\([a-z_]+_[a-z_]+\)', '', translated)
return translated
except Exception as e:
logger.error(f"Translate error: {str(e)}")
return ""
async def get_user_lang(conn, user_id: int) -> str:
"""Получает язык интерфейса пользователя."""
lang = await conn.fetchval("""
SELECT app_language FROM users WHERE telegram_id = $1
""", user_id)
return lang if lang in ["pl", "ru", "uk", "en"] else "pl"
@router.post("/send")
async def send_support_message(request: Request):
"""
Пользователь отправляет сообщение в поддержку.
Если у пользователя нет открытого тикета — создаётся новый.
"""
telegram_id, _ = await require_auth(request)
body = await request.json()
message = body.get("message", "").strip()
if not message:
raise HTTPException(400, "message is required")
if len(message) > 1000:
raise HTTPException(400, "Message too long (max 1000 characters)")
pool = await get_pool()
async with pool.acquire() as conn:
# Ищем открытый тикет пользователя
ticket = await conn.fetchrow("""
SELECT id FROM support_tickets
WHERE user_id = $1 AND status = 'open'
ORDER BY id DESC
LIMIT 1
""", telegram_id)
ticket_id = None
if ticket:
ticket_id = ticket["id"]
# Обновляем updated_at
await conn.execute("""
UPDATE support_tickets SET updated_at = NOW() WHERE id = $1
""", ticket_id)
else:
# Создаём новый тикет
new_ticket = await conn.fetchrow("""
INSERT INTO support_tickets (user_id, status)
VALUES ($1, 'open') RETURNING id
""", telegram_id)
ticket_id = new_ticket["id"]
# Сохраняем сообщение (юзер написал — админ ещё не прочитал)
await conn.execute("""
INSERT INTO support_messages (ticket_id, sender_id, message, is_read)
VALUES ($1, $2, $3, FALSE)
""", ticket_id, telegram_id, message)
return {"status": "ok", "ticket_id": ticket_id}
@router.get("/user-tickets")
async def get_user_tickets(request: Request):
"""Пользователь получает список своих тикетов."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
tickets = await conn.fetch("""
SELECT id, status, created_at, updated_at
FROM support_tickets
WHERE user_id = $1
ORDER BY updated_at DESC
""", telegram_id)
return {"tickets": [dict(t) for t in tickets]}
@router.get("/user-messages/{ticket_id}")
async def get_user_messages(request: Request, ticket_id: int):
"""Пользователь получает сообщения своего тикета и отмечает ответы админа прочитанными."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
# Проверяем, что тикет принадлежит пользователю
ticket = await conn.fetchrow("""
SELECT id FROM support_tickets WHERE id = $1 AND user_id = $2
""", ticket_id, telegram_id)
if not ticket:
raise HTTPException(404, "Ticket not found")
# Отмечаем сообщения админа как прочитанные
await conn.execute("""
UPDATE support_messages SET is_read = TRUE
WHERE ticket_id = $1 AND sender_id != $2
""", ticket_id, telegram_id)
messages = await conn.fetch("""
SELECT id, sender_id, message, is_read, created_at
FROM support_messages
WHERE ticket_id = $1
ORDER BY id ASC
""", ticket_id)
return {"messages": [dict(m) for m in messages]}
@router.get("/user-unread-count")
async def get_user_unread_count(request: Request):
"""Пользователь получает количество непрочитанных ответов админа."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
count = await conn.fetchval("""
SELECT COUNT(*) FROM support_messages m
JOIN support_tickets t ON m.ticket_id = t.id
WHERE t.user_id = $1
AND m.is_read = FALSE
AND m.sender_id != $1
""", telegram_id)
return {"unread_count": count or 0}
@router.post("/delete-ticket/{ticket_id}")
async def delete_ticket(request: Request, ticket_id: int):
"""
Удаляет тикет.
Может удалить: сам пользователь (свой тикет) или админ (любой).
"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
# Проверяем права
if telegram_id in ADMIN_IDS:
# Админ может удалить любой
result = await conn.execute("DELETE FROM support_tickets WHERE id = $1", ticket_id)
else:
# Пользователь может удалить только свой
result = await conn.execute("""
DELETE FROM support_tickets WHERE id = $1 AND user_id = $2
""", ticket_id, telegram_id)
affected = int(result.split()[-1])
if affected == 0:
raise HTTPException(404, "Ticket not found or no permission")
return {"status": "ok"}
# ============ АДМИНСКИЕ ЭНДПОИНТЫ ============
@router.get("/admin-tickets")
async def get_admin_tickets(request: Request):
"""Админ получает список всех тикетов."""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
pool = await get_pool()
async with pool.acquire() as conn:
tickets = await conn.fetch("""
SELECT
t.id,
t.user_id,
t.status,
t.created_at,
t.updated_at,
u.username,
u.app_language,
(SELECT COUNT(*) FROM support_messages m WHERE m.ticket_id = t.id AND m.is_read = FALSE) as unread_count,
(SELECT message FROM support_messages m WHERE m.ticket_id = t.id ORDER BY m.id DESC LIMIT 1) as last_message
FROM support_tickets t
LEFT JOIN users u ON t.user_id = u.telegram_id
ORDER BY t.updated_at DESC
""")
return {"tickets": [dict(t) for t in tickets]}
@router.get("/admin-messages/{ticket_id}")
async def get_admin_messages(request: Request, ticket_id: int):
"""Админ получает сообщения тикета и отмечает их как прочитанные."""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
pool = await get_pool()
async with pool.acquire() as conn:
ticket = await conn.fetchrow("""
SELECT id FROM support_tickets WHERE id = $1
""", ticket_id)
if not ticket:
raise HTTPException(404, "Ticket not found")
# Отмечаем сообщения пользователя как прочитанные
await conn.execute("""
UPDATE support_messages SET is_read = TRUE
WHERE ticket_id = $1 AND sender_id != $2
""", ticket_id, telegram_id)
messages = await conn.fetch("""
SELECT id, sender_id, message, is_read, created_at
FROM support_messages
WHERE ticket_id = $1
ORDER BY id ASC
""", ticket_id)
return {"messages": [dict(m) for m in messages]}
@router.post("/admin-reply")
async def admin_reply(request: Request):
"""Админ отвечает пользователю."""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
body = await request.json()
ticket_id = body.get("ticket_id")
message = body.get("message", "").strip()
if not ticket_id or not message:
raise HTTPException(400, "ticket_id and message required")
if len(message) > 1000:
raise HTTPException(400, "Message too long (max 1000 characters)")
pool = await get_pool()
async with pool.acquire() as conn:
ticket = await conn.fetchrow("""
SELECT id FROM support_tickets WHERE id = $1
""", ticket_id)
if not ticket:
raise HTTPException(404, "Ticket not found")
# 🔧 ИСПРАВЛЕНО: админ написал — юзер ЕЩЁ НЕ ПРОЧИТАЛ, поэтому is_read = FALSE
# Раньше было TRUE, из-за чего бейдж непрочитанных у пользователя не появлялся
await conn.execute("""
INSERT INTO support_messages (ticket_id, sender_id, message, is_read)
VALUES ($1, $2, $3, FALSE)
""", ticket_id, telegram_id, message)
await conn.execute("""
UPDATE support_tickets SET updated_at = NOW() WHERE id = $1
""", ticket_id)
return {"status": "ok"}
@router.get("/unread-count")
async def get_unread_count(request: Request):
"""Админ получает количество непрочитанных сообщений."""
telegram_id, _ = await require_auth(request)
if telegram_id not in ADMIN_IDS:
raise HTTPException(403, "Access denied")
pool = await get_pool()
async with pool.acquire() as conn:
count = await conn.fetchval("""
SELECT COUNT(*) FROM support_messages
WHERE is_read = FALSE AND sender_id != $1
""", telegram_id)
return {"unread_count": count or 0}
@router.post("/translate")
async def translate_message(request: Request):
"""
Перевод сообщения через AI (MiniMax-M3).
Доступно и пользователю, и админу.
"""
telegram_id, _ = await require_auth(request)
body = await request.json()
text = body.get("text", "").strip()
target_lang = body.get("target_lang", "ru")
if not text:
raise HTTPException(400, "text is required")
if len(text) > 1000:
raise HTTPException(400, "Text too long (max 1000 characters)")
if target_lang not in ["pl", "ru", "uk", "en"]:
target_lang = "ru"
translated = await call_ai_translate(text, target_lang)
if not translated:
raise HTTPException(500, "Translation failed")
return {"translation": translated}