359 lines
13 KiB
Python
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} |