intrabot/web/server.py
dv a8723bb3ee
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
чиню телегу
2026-03-15 23:51:59 +03:00

283 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)