initial commit (backend)
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
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}
|
||||
Reference in New Issue
Block a user