283 lines
10 KiB
Python
283 lines
10 KiB
Python
import json
|
||
import logging
|
||
import os
|
||
import queue
|
||
import threading
|
||
|
||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context, abort
|
||
import hashlib
|
||
import hmac
|
||
import json as _json
|
||
from functools import wraps
|
||
|
||
import config
|
||
import database
|
||
import events
|
||
from intradesk import reference
|
||
from intradesk import api as intradesk_api
|
||
from intradesk.handler import handle_webhook
|
||
|
||
log = logging.getLogger(__name__)
|
||
app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"))
|
||
|
||
# ── Очередь вебхуков (последовательная обработка) ─────────────────────────────
|
||
|
||
_webhook_queue: queue.Queue = queue.Queue()
|
||
|
||
|
||
def _webhook_worker():
|
||
while True:
|
||
data = _webhook_queue.get()
|
||
try:
|
||
handle_webhook(data)
|
||
except Exception:
|
||
log.exception("Unhandled error in webhook worker")
|
||
finally:
|
||
_webhook_queue.task_done()
|
||
|
||
|
||
threading.Thread(target=_webhook_worker, daemon=True, name="webhook-worker").start()
|
||
|
||
|
||
# ── Авторизация ────────────────────────────────────────────────────────────────
|
||
|
||
def check_basic_auth(username, password):
|
||
"""Проверяет Basic Auth. Логин фиксированный 'admin'."""
|
||
if not config.DASHBOARD_PASSWORD:
|
||
return True # Пароль не задан — всё открыто
|
||
return username == "admin" and password == config.DASHBOARD_PASSWORD
|
||
|
||
|
||
def require_basic_auth(f):
|
||
"""Декоратор: требует Basic Auth если DASHBOARD_PASSWORD задан."""
|
||
@wraps(f)
|
||
def decorated(*args, **kwargs):
|
||
if not config.DASHBOARD_PASSWORD:
|
||
return f(*args, **kwargs)
|
||
auth = request.authorization
|
||
if not auth or not check_basic_auth(auth.username, auth.password):
|
||
return Response(
|
||
"Требуется авторизация",
|
||
401,
|
||
{"WWW-Authenticate": 'Basic realm="IntradBot Dashboard"'},
|
||
)
|
||
return f(*args, **kwargs)
|
||
return decorated
|
||
|
||
|
||
def verify_task_token(task_number: str, exp: str, sig: str) -> bool:
|
||
"""Проверяет HMAC-подпись токена из URL Mini App."""
|
||
if not config.DASHBOARD_PASSWORD:
|
||
return True # Защита отключена
|
||
if not exp or not sig:
|
||
return False
|
||
try:
|
||
import time as _time
|
||
if int(exp) < int(_time.time()):
|
||
log.warning(f"Token expired for task {task_number}")
|
||
return False
|
||
msg = f"{task_number}:{exp}".encode()
|
||
expected = hmac.new(config.MINIAPP_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:16]
|
||
return hmac.compare_digest(expected, sig)
|
||
except Exception as e:
|
||
log.warning(f"Token verification error: {e}")
|
||
return False
|
||
|
||
|
||
def require_task_token(f):
|
||
"""Декоратор: проверяет подписанный токен из URL для API Mini App."""
|
||
@wraps(f)
|
||
def decorated(*args, **kwargs):
|
||
if not config.DASHBOARD_PASSWORD:
|
||
return f(*args, **kwargs)
|
||
# Берём номер задачи из любого параметра запроса
|
||
task = (request.args.get("number")
|
||
or request.args.get("task_id")
|
||
or kwargs.get("file_id", ""))
|
||
exp = request.args.get("exp", "")
|
||
sig = request.args.get("sig", "")
|
||
if not verify_task_token(str(task), exp, sig):
|
||
return jsonify({"error": "Unauthorized"}), 401
|
||
return f(*args, **kwargs)
|
||
return decorated
|
||
|
||
|
||
# ── Webhook от Intradesk ───────────────────────────────────────────────────────
|
||
|
||
@app.route("/webhook", methods=["POST"])
|
||
def webhook():
|
||
if config.WEBHOOK_SECRET:
|
||
secret = request.headers.get("X-Webhook-Secret") or request.args.get("secret", "")
|
||
if secret != config.WEBHOOK_SECRET:
|
||
log.warning("Webhook: unauthorized request")
|
||
return jsonify({"error": "Unauthorized"}), 401
|
||
|
||
data = request.get_json(silent=True)
|
||
if not data:
|
||
return jsonify({"error": "Invalid JSON"}), 400
|
||
|
||
_webhook_queue.put(data)
|
||
return jsonify({"ok": True}), 200
|
||
|
||
|
||
@app.route("/health")
|
||
def health():
|
||
return jsonify({"status": "ok"}), 200
|
||
|
||
|
||
@app.route("/version")
|
||
def version():
|
||
return jsonify({
|
||
"commit": os.environ.get("GIT_COMMIT", "dev"),
|
||
"built_at": os.environ.get("BUILD_TIME", "unknown"),
|
||
})
|
||
|
||
|
||
# ── Дашборд ────────────────────────────────────────────────────────────────────
|
||
|
||
@app.route("/")
|
||
@require_basic_auth
|
||
def dashboard():
|
||
return render_template("dashboard.html")
|
||
|
||
|
||
@app.route("/miniapp")
|
||
def miniapp():
|
||
return render_template("miniapp.html")
|
||
|
||
|
||
@app.route("/stream")
|
||
@require_basic_auth
|
||
def stream():
|
||
q = events.subscribe()
|
||
|
||
def generate():
|
||
try:
|
||
while True:
|
||
try:
|
||
data = q.get(timeout=25)
|
||
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||
except Exception:
|
||
yield 'data: {"type":"ping"}\n\n'
|
||
finally:
|
||
events.unsubscribe(q)
|
||
|
||
return Response(
|
||
stream_with_context(generate()),
|
||
mimetype="text/event-stream",
|
||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||
)
|
||
|
||
|
||
# ── API заявок ─────────────────────────────────────────────────────────────────
|
||
|
||
@app.route("/api/tasks/search")
|
||
#@require_task_token
|
||
def api_tasks_search():
|
||
try:
|
||
number = int(request.args.get("number", 0))
|
||
task = intradesk_api.get_task(number)
|
||
if task is None:
|
||
return jsonify({"error": "Заявка не найдена"}), 404
|
||
return jsonify(task)
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 400
|
||
|
||
|
||
@app.route("/api/tasks/active")
|
||
#@require_task_token
|
||
def api_tasks_active():
|
||
filter_expr = request.args.get(
|
||
"filter", config.ACTIVE_TASKS_FILTER
|
||
)
|
||
tasks = intradesk_api.get_tasks(filter_expr, top=50)
|
||
return jsonify(tasks)
|
||
|
||
|
||
@app.route("/api/task/lifetime")
|
||
#@require_task_token
|
||
def api_task_lifetime():
|
||
try:
|
||
task_id = int(request.args.get("task_id", 0))
|
||
top = int(request.args.get("top", 20))
|
||
data = intradesk_api.get_task_lifetime(task_id, top=top)
|
||
return jsonify(data)
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 400
|
||
|
||
|
||
# ── Справочники ────────────────────────────────────────────────────────────────
|
||
|
||
@app.route("/api/ref/<entity>")
|
||
def api_ref(entity):
|
||
items = database.get_all_ref(entity)
|
||
if not items:
|
||
fallback = {"statuses": reference.STATUSES, "priorities": reference.PRIORITIES}
|
||
d = fallback.get(entity, {})
|
||
items = [{"id": k, "name": v} for k, v in d.items()]
|
||
return jsonify(items)
|
||
|
||
|
||
# ── Изменение заявки ───────────────────────────────────────────────────────────
|
||
|
||
@app.route("/api/task/preview", methods=["POST"])
|
||
def api_task_preview():
|
||
data = request.get_json() or {}
|
||
number = data.get("number")
|
||
blocks = data.get("blocks", {})
|
||
body = {"number": number, "blocks": blocks, "Channel": "api"}
|
||
return jsonify({
|
||
"method": "PUT",
|
||
"url": "https://apigw.intradesk.ru/changes/v3/tasks/?ApiKey=***",
|
||
"body": body,
|
||
})
|
||
|
||
|
||
@app.route("/api/task/execute", methods=["POST"])
|
||
def api_task_execute():
|
||
data = request.get_json() or {}
|
||
number = data.get("number")
|
||
blocks = data.get("blocks", {})
|
||
if not number:
|
||
return jsonify({"success": False, "error": "Не указан номер заявки"}), 400
|
||
success = intradesk_api.update_task(int(number), blocks)
|
||
return jsonify({"success": success})
|
||
|
||
|
||
|
||
# ── Прокси для файлов Intradesk (скрывает API ключ) ───────────────────────────
|
||
|
||
@app.route("/api/files/<file_id>")
|
||
def api_file(file_id):
|
||
"""Проксирует файл из Intradesk, подставляя API ключ на сервере."""
|
||
import requests as req
|
||
thumb = request.args.get("thumb") == "1"
|
||
url = f"https://apigw.intradesk.ru/files/api/tasks/files/{file_id}"
|
||
if thumb:
|
||
url += "/thumbnail"
|
||
try:
|
||
r = req.get(url, params={"ApiKey": config.API_KEY}, timeout=30, stream=True)
|
||
r.raise_for_status()
|
||
content_type = r.headers.get("Content-Type", "application/octet-stream")
|
||
return Response(r.content, content_type=content_type)
|
||
except Exception as e:
|
||
log.error(f"File proxy error for {file_id}: {e}")
|
||
return jsonify({"error": "Файл недоступен"}), 502
|
||
|
||
def create_app():
|
||
"""Фабрика приложения — используется Gunicorn и тестами."""
|
||
return app
|
||
|
||
|
||
def start_server():
|
||
"""Запуск через встроенный Flask — только для локальной разработки."""
|
||
ssl_cert = os.path.join("/app/ssl", "cert.pem")
|
||
ssl_key = os.path.join("/app/ssl", "key.pem")
|
||
|
||
if os.path.exists(ssl_cert) and os.path.exists(ssl_key):
|
||
log.info(f"Web server → https://0.0.0.0:{config.PORT} (SSL, dev mode)")
|
||
app.run(host="0.0.0.0", port=config.PORT,
|
||
ssl_context=(ssl_cert, ssl_key), use_reloader=False)
|
||
else:
|
||
log.info(f"Web server → http://0.0.0.0:{config.PORT} (dev mode, no SSL)")
|
||
app.run(host="0.0.0.0", port=config.PORT, use_reloader=False)
|