commit fe02b5139ac09d7dc90a748a4144dc3adf6c892d Author: caps Date: Tue Feb 24 15:15:23 2026 +0300 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fba44f5 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +TELEGRAM_TOKEN=7586393409:AAFBhpdkjWU8-Fn0B6vv-VLR5kUQ6iXKXss +INTRADESK_API_KEY=26221B839B6643E68345FD26BF +CHANNEL_ID=-1003708972637 +BASE_TASK_URL=https://cleverparking.intradesk.ru/task/list/ +WEBHOOK_SECRET=capsspac +PORT=8080 +DB_FILE=/app/data/intradbot.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2428a1c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN mkdir -p /app/data + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "intradbot.py"] diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/telegram.py b/bot/telegram.py new file mode 100644 index 0000000..d29bc8a --- /dev/null +++ b/bot/telegram.py @@ -0,0 +1,186 @@ +import logging + +import telebot +from telebot import types +from telebot.apihelper import ApiTelegramException + +import config +import database +from intradesk import reference +from intradesk import api as intradesk_api + +log = logging.getLogger(__name__) +bot = telebot.TeleBot(config.TOKEN, parse_mode="Markdown") + + +# ── Кнопки под постом ───────────────────────────────────────────────────────── + +def make_task_buttons(task_number: str, closed: bool = False) -> types.InlineKeyboardMarkup: + markup = types.InlineKeyboardMarkup(row_width=2) + if not closed: + markup.add( + types.InlineKeyboardButton("📋 Статус", callback_data=f"st:{task_number}"), + types.InlineKeyboardButton("👤 Исполнитель", callback_data=f"ex:{task_number}"), + ) + return markup + + +# ── Отправка / редактирование поста ─────────────────────────────────────────── + +def send_or_edit( + task_id: int, + task_number: str, + full_text: str, + update_summary: str = None, + closed: bool = False, +): + markup = make_task_buttons(task_number, closed=closed) + msg_id = database.get_message_id(task_id) + + if msg_id: + try: + bot.edit_message_text( + full_text, + config.CHANNEL_ID, + msg_id, + parse_mode="Markdown", + disable_web_page_preview=True, + reply_markup=markup if not closed else None, + ) + except ApiTelegramException as e: + if "message is not modified" not in str(e): + log.warning(f"edit_message failed for task {task_id}: {e}") + + if update_summary: + try: + bot.send_message( + config.CHANNEL_ID, + update_summary, + reply_to_message_id=msg_id, + parse_mode="Markdown", + ) + except ApiTelegramException as e: + log.warning(f"send reply failed for task {task_id}: {e}") + else: + try: + msg = bot.send_message( + config.CHANNEL_ID, + full_text, + parse_mode="Markdown", + disable_web_page_preview=True, + reply_markup=markup, + ) + database.save_message_id(task_id, msg.message_id, task_number) + except ApiTelegramException as e: + log.error(f"send_message failed for task {task_id}: {e}") + + +# ── Callbacks: смена статуса ────────────────────────────────────────────────── + +@bot.callback_query_handler(func=lambda c: c.data.startswith("st:")) +def cb_show_statuses(call): + """Показывает список статусов для выбора.""" + task_number = call.data[3:] + markup = types.InlineKeyboardMarkup(row_width=1) + for sid, name in reference.STATUSES.items(): + markup.add(types.InlineKeyboardButton(name, callback_data=f"ss:{task_number}:{sid}")) + markup.add(types.InlineKeyboardButton("✖ Отмена", callback_data="del")) + + try: + bot.send_message( + call.message.chat.id, + f"Заявка *\\#{task_number}* — выберите новый статус:", + parse_mode="Markdown", + reply_markup=markup, + reply_to_message_id=call.message.message_id, + ) + bot.answer_callback_query(call.id) + except Exception as e: + bot.answer_callback_query(call.id, str(e), show_alert=True) + + +@bot.callback_query_handler(func=lambda c: c.data.startswith("ss:")) +def cb_set_status(call): + """Применяет выбранный статус через API.""" + _, task_number, status_id = call.data.split(":") + status_name = reference.STATUSES.get(int(status_id), f"#{status_id}") + + success = intradesk_api.change_status(int(task_number), int(status_id)) + if success: + bot.answer_callback_query(call.id, f"✅ Статус → {status_name}") + _delete_message(call.message.chat.id, call.message.message_id) + else: + bot.answer_callback_query(call.id, "❌ Ошибка при изменении статуса", show_alert=True) + + +# ── Callbacks: назначение исполнителя ───────────────────────────────────────── + +@bot.callback_query_handler(func=lambda c: c.data.startswith("ex:")) +def cb_show_executors(call): + """Показывает список сотрудников для назначения исполнителем.""" + task_number = call.data[3:] + employees = database.get_all_ref("employees") + + if not employees: + bot.answer_callback_query( + call.id, + "Список сотрудников не загружен. Обновите справочники.", + show_alert=True, + ) + return + + markup = types.InlineKeyboardMarkup(row_width=1) + for emp in employees[:20]: # Telegram ограничивает кол-во кнопок + markup.add(types.InlineKeyboardButton( + emp["name"], + callback_data=f"se:{task_number}:{emp['id']}", + )) + markup.add(types.InlineKeyboardButton("✖ Отмена", callback_data="del")) + + try: + bot.send_message( + call.message.chat.id, + f"Заявка *\\#{task_number}* — выберите исполнителя:", + parse_mode="Markdown", + reply_markup=markup, + reply_to_message_id=call.message.message_id, + ) + bot.answer_callback_query(call.id) + except Exception as e: + bot.answer_callback_query(call.id, str(e), show_alert=True) + + +@bot.callback_query_handler(func=lambda c: c.data.startswith("se:")) +def cb_set_executor(call): + """Назначает выбранного сотрудника исполнителем через API.""" + _, task_number, emp_id = call.data.split(":") + emp_name = database.resolve("employees", int(emp_id)) or f"#{emp_id}" + + success = intradesk_api.assign_executor(int(task_number), user_id=int(emp_id)) + if success: + bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}") + _delete_message(call.message.chat.id, call.message.message_id) + else: + bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True) + + +# ── Общий callback: удалить меню ────────────────────────────────────────────── + +@bot.callback_query_handler(func=lambda c: c.data == "del") +def cb_delete(call): + bot.answer_callback_query(call.id) + _delete_message(call.message.chat.id, call.message.message_id) + + +def _delete_message(chat_id, message_id): + try: + bot.delete_message(chat_id, message_id) + except Exception: + pass + + +# ── Запуск бота ─────────────────────────────────────────────────────────────── + +def start_bot(): + log.info("Telegram bot started (polling)...") + bot.infinity_polling() diff --git a/config.py b/config.py new file mode 100644 index 0000000..b9b97c6 --- /dev/null +++ b/config.py @@ -0,0 +1,9 @@ +import os + +TOKEN = os.environ.get("TELEGRAM_TOKEN", "") +API_KEY = os.environ.get("INTRADESK_API_KEY", "") +BASE_TASK_URL = os.environ.get("BASE_TASK_URL", "https://cleverparking.intradesk.ru/task/list/") +CHANNEL_ID = int(os.environ.get("CHANNEL_ID", "0")) +DB_FILE = os.environ.get("DB_FILE", "/app/data/intradbot.db") +WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "") +PORT = int(os.environ.get("PORT", "8080")) diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/database.py b/database.py new file mode 100644 index 0000000..05b48d0 --- /dev/null +++ b/database.py @@ -0,0 +1,84 @@ +import sqlite3 +import config + + +def _connect(): + conn = sqlite3.connect(config.DB_FILE) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + with _connect() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS messages ( + task_id INTEGER PRIMARY KEY, + message_id INTEGER NOT NULL, + task_number TEXT + ) + """) + # Универсальная таблица справочников: entity = 'status'|'priority'|'service'|... + conn.execute(""" + CREATE TABLE IF NOT EXISTS ref_data ( + entity TEXT NOT NULL, + id INTEGER NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (entity, id) + ) + """) + conn.commit() + + +# ── messages ────────────────────────────────────────────────────────────────── + +def get_message_id(task_id: int): + with _connect() as conn: + row = conn.execute( + "SELECT message_id FROM messages WHERE task_id = ?", (task_id,) + ).fetchone() + return row["message_id"] if row else None + + +def save_message_id(task_id: int, message_id: int, task_number: str = None): + with _connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO messages (task_id, message_id, task_number) VALUES (?, ?, ?)", + (task_id, message_id, task_number), + ) + conn.commit() + + +# ── ref_data ────────────────────────────────────────────────────────────────── + +def save_ref_data(entity: str, items: list[tuple[int, str]]): + """Перезаписывает весь справочник entity списком пар (id, name).""" + with _connect() as conn: + conn.execute("DELETE FROM ref_data WHERE entity = ?", (entity,)) + conn.executemany( + "INSERT INTO ref_data (entity, id, name) VALUES (?, ?, ?)", + [(entity, item_id, name) for item_id, name in items], + ) + conn.commit() + + +def resolve(entity: str, item_id: int | None) -> str | None: + """Возвращает name по entity + id. None если не найдено.""" + if item_id is None: + return None + with _connect() as conn: + row = conn.execute( + "SELECT name FROM ref_data WHERE entity = ? AND id = ?", + (entity, item_id), + ).fetchone() + return row["name"] if row else None + + +def get_all_ref(entity: str) -> list[dict]: + """Возвращает все записи справочника как список {id, name}.""" + with _connect() as conn: + rows = conn.execute( + "SELECT id, name FROM ref_data WHERE entity = ? ORDER BY name", + (entity,), + ).fetchall() + return [{"id": r["id"], "name": r["name"]} for r in rows] + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ec51728 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + intradbot: + build: . + restart: always + env_file: .env + ports: + - "8080:8080" + volumes: + - ./data:/app/data # БД и логи хранятся вне контейнера diff --git a/events.py b/events.py new file mode 100644 index 0000000..0568da2 --- /dev/null +++ b/events.py @@ -0,0 +1,50 @@ +""" +Шина SSE-событий. +Позволяет webhook_handler.py публиковать события, +а webhook_server.py — раздавать их браузеру через /stream. +""" + +import json +import queue +import threading +from collections import deque + +_history: deque = deque(maxlen=100) +_clients: list[queue.Queue] = [] +_lock = threading.Lock() + + +def broadcast(data: dict): + """Публикует событие всем подключённым SSE-клиентам и в историю.""" + _history.append(data) + with _lock: + for q in list(_clients): + try: + q.put_nowait(data) + except queue.Full: + pass + + +def subscribe() -> queue.Queue: + """Подписывает нового SSE-клиента. Возвращает очередь с историей.""" + q: queue.Queue = queue.Queue(maxsize=200) + with _lock: + _clients.append(q) + for item in list(_history): + try: + q.put_nowait(item) + except queue.Full: + break + return q + + +def unsubscribe(q: queue.Queue): + with _lock: + try: + _clients.remove(q) + except ValueError: + pass + + +def get_history() -> list: + return list(_history) diff --git a/intradbot.py b/intradbot.py new file mode 100644 index 0000000..8c6d34e --- /dev/null +++ b/intradbot.py @@ -0,0 +1,46 @@ +import logging +import os +import threading +import time + +import schedule + +import config +import database +from intradesk import reference +from bot.telegram import start_bot +from web.server import start_server + +os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[ + logging.StreamHandler(), + logging.FileHandler(os.path.join(os.path.dirname(config.DB_FILE), "bot.log")), + ], +) +log = logging.getLogger(__name__) + + +def main(): + log.info("Starting IntradBot...") + + database.init_db() + reference.seed_static() + reference.load_all() + + threading.Thread(target=start_bot, daemon=True).start() + threading.Thread(target=start_server, daemon=True).start() + + schedule.every().monday.at("10:00").do(reference.load_all) + + log.info("IntradBot is running. Waiting for webhooks...") + while True: + schedule.run_pending() + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/intradesk/__init__.py b/intradesk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/intradesk/api.py b/intradesk/api.py new file mode 100644 index 0000000..233b08b --- /dev/null +++ b/intradesk/api.py @@ -0,0 +1,262 @@ +""" +Клиент Intradesk API. + +Базовые URL: + GET задачи: https://apigw.intradesk.ru/tasklist/odata/v3/tasks + PUT задачи: https://apigw.intradesk.ru/changes/v3/tasks/ + GET история: https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime/{task_id}/full + +Аутентификация: ?ApiKey=<ключ> в строке запроса. +""" + +import json +import logging +from datetime import datetime, timezone +from typing import Any + +import requests + +import config + +log = logging.getLogger(__name__) + +_TASKS_URL = "https://apigw.intradesk.ru/tasklist/odata/v3/tasks" +_CHANGES_URL = "https://apigw.intradesk.ru/changes/v3/tasks/" +_HISTORY_URL = "https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime" + + +# ── вспомогательные ──────────────────────────────────────────────────────────── + +def _get(url: str, params: dict = None, timeout: int = 30) -> dict | None: + params = {**(params or {}), "ApiKey": config.API_KEY} + try: + r = requests.get(url, params=params, timeout=timeout) + r.raise_for_status() + return r.json() + except requests.exceptions.RequestException as e: + log.error(f"GET {url} failed: {e}") + return None + + +def _put(url: str, body: dict, timeout: int = 30) -> bool: + params = {"ApiKey": config.API_KEY} + try: + r = requests.put(url, json=body, params=params, timeout=timeout) + r.raise_for_status() + return True + except requests.exceptions.RequestException as e: + log.error(f"PUT {url} failed: {e}") + return False + + +def _jv(value: Any) -> str: + """Сериализует значение в строку-JSON (формат, которого ожидает API изменений).""" + return json.dumps({"value": value}, ensure_ascii=False) + + +# ── задачи ───────────────────────────────────────────────────────────────────── + +def get_task(task_number: int) -> dict | None: + """ + Возвращает полный объект задачи по её номеру или None при ошибке. + + GET /tasklist/odata/v3/tasks?$filter=tasknumber eq {number} + """ + data = _get(_TASKS_URL, {"$filter": f"tasknumber eq {task_number}"}) + if not data: + return None + items = data.get("value", []) + return items[0] if items else None + + +def get_tasks(filter_expr: str = "", top: int = 150, order_by: str = "updatedat desc") -> list[dict]: + """ + Возвращает список задач с поддержкой пагинации. + + Примеры filter_expr: + "(status ne 68045) and (executorgroup eq 243025)" + "tasknumber eq 31" + """ + params: dict | None = {"$filter": filter_expr, "$top": top, "$orderby": order_by} + url: str | None = _TASKS_URL + all_tasks: list[dict] = [] + + while url: + data = _get(url, params) + if not data: + break + all_tasks.extend(data.get("value", [])) + url = data.get("@odata.nextLink") + params = None # nextLink уже содержит все параметры + + return all_tasks + + +# ── история (lifetime) ───────────────────────────────────────────────────────── + +def get_task_lifetime( + task_id: int, + events: list[int] | None = None, + top: int = 50, + sort_desc: bool = True, + date_start: datetime | None = None, + date_end: datetime | None = None, +) -> list[dict]: + """ + Возвращает историю событий задачи. + + GET /taskhistory/api/v2.0/lifetime/{task_id}/full + + Параметры: + events — список кодов событий для фильтрации (None = все) + top — максимум записей + sort_desc — по убыванию даты + date_start — фильтр «с» (UTC datetime) + date_end — фильтр «по» (UTC datetime) + + Коды событий: см. reference.EVENT_TYPES + """ + url = f"{_HISTORY_URL}/{task_id}/full" + params: dict = { + "sortDirection": "Desc" if sort_desc else "Asc", + "top": top, + } + + if date_start: + params["dateStart"] = date_start.strftime("%Y-%m-%dT%H:%M:%S.0000000Z") + if date_end: + params["dateEnd"] = date_end.strftime("%Y-%m-%dT%H:%M:%S.0000000Z") + + # events передаются как повторяющийся параметр: &events=10&events=50&events=30 + base_params = {**params, "ApiKey": config.API_KEY} + try: + r = requests.get( + url, + params=base_params if not events else {**base_params}, + timeout=30, + ) + if events: + # requests не поддерживает дублирующиеся ключи напрямую — строим вручную + from urllib.parse import urlencode + qs = urlencode({**base_params}) + "".join(f"&events={e}" for e in events) + r = requests.get(f"{url}?{qs}", timeout=30) + r.raise_for_status() + return r.json().get("data", []) + except requests.exceptions.RequestException as e: + log.error(f"GET lifetime/{task_id} failed: {e}") + return [] + + +# ── изменение задачи ─────────────────────────────────────────────────────────── + +def update_task(task_number: int, blocks: dict, channel: str = "api") -> bool: + """ + Изменяет поля задачи. + + PUT /changes/v3/tasks/ + + blocks — словарь {поле: уже_сериализованное_значение_json_строкой}. + Для удобства используй вспомогательные функции ниже. + + Пример: + update_task(272, { + "status": '{"value": 68051}', + "comment": '{"value":"Принято в работу"}', + }) + """ + body = {"number": task_number, "blocks": blocks, "Channel": channel} + return _put(_CHANGES_URL, body) + + +# ── удобные обёртки ──────────────────────────────────────────────────────────── + +def change_status(task_number: int, status_id: int) -> bool: + """Меняет статус задачи. status_id из reference.STATUSES.""" + return update_task(task_number, {"status": _jv(status_id)}) + + +def add_comment(task_number: int, text: str) -> bool: + """Добавляет публичный комментарий к задаче.""" + return update_task(task_number, {"comment": _jv(text)}) + + +def add_private_comment(task_number: int, text: str) -> bool: + """Добавляет приватный (скрытый) комментарий к задаче.""" + return update_task(task_number, {"privatecomment": _jv(text)}) + + +def assign_executor( + task_number: int, + user_id: int | None = None, + group_id: int | None = None, +) -> bool: + """ + Назначает исполнителя и/или группу исполнителей. + + assign_executor(272, user_id=109747) # только сотрудник + assign_executor(272, group_id=243025) # только группа + assign_executor(272, group_id=243025, user_id=109747) # группа + сотрудник + """ + value: dict = {} + if user_id: + value["userid"] = user_id + if group_id: + value["groupid"] = group_id + if not value: + log.warning("assign_executor: нужно указать user_id или group_id") + return False + return update_task(task_number, {"executor": _jv(value)}) + + +def set_resolution_date(task_number: int, dt: datetime) -> bool: + """ + Устанавливает плановый срок выполнения. + dt должен быть UTC-datetime. + + Пример: set_resolution_date(272, datetime(2025, 3, 15, 15, 0, tzinfo=timezone.utc)) + """ + iso = dt.strftime("%Y-%m-%dT%H:%M:%S.0000000Z") + return update_task(task_number, {"resolutiondateplan": _jv(iso)}) + + +def set_service(task_number: int, service_path: str) -> bool: + """ + Устанавливает сервис задачи. + service_path — путь сервиса вида "84681/84683/" (из карточки сервиса). + """ + return update_task(task_number, {"service": _jv(service_path)}) + + +def set_task_type(task_number: int, tasktype_id: int) -> bool: + """Устанавливает тип задачи.""" + return update_task(task_number, {"tasktype": _jv(tasktype_id)}) + + +def set_tags(task_number: int, tag_ids: list[int]) -> bool: + """Устанавливает теги задачи (заменяет все существующие).""" + return update_task(task_number, {"tags": _jv(tag_ids)}) + + +def set_priority(task_number: int, priority_id: int) -> bool: + """Устанавливает приоритет задачи. priority_id из reference.PRIORITIES.""" + return update_task(task_number, {"priority": _jv(priority_id)}) + + +def set_initiator( + task_number: int, + user_id: int | None = None, + group_id: int | None = None, +) -> bool: + """ + Устанавливает инициатора (заявителя). + + set_initiator(272, user_id=109747) # сотрудник + set_initiator(272, group_id=109747) # клиент + set_initiator(272, group_id=109747, user_id=31974) # клиент + пользователь + """ + value: dict = {} + if user_id: + value["userid"] = user_id + if group_id: + value["groupid"] = group_id + return update_task(task_number, {"initiator": _jv(value)}) diff --git a/intradesk/formatter.py b/intradesk/formatter.py new file mode 100644 index 0000000..f3d5d9b --- /dev/null +++ b/intradesk/formatter.py @@ -0,0 +1,275 @@ +import json +import logging + +import config +import database +from intradesk import reference + +log = logging.getLogger(__name__) + +# Маппинг blockname → entity в БД справочников +BLOCK_TO_ENTITY: dict[str, str] = { + "status": "statuses", + "priority": "priorities", + "executorgroup": "groups", + "executor": "employees", + "service": "services", + "tasktype": "tasktypes", + "initiator": "employees", + "workflow": "workflows", + "sla": "sla", +} + +# Человекочитаемые названия полей +FIELD_LABELS = { + "status": "Статус", + "priority": "Приоритет", + "executor": "Исполнитель", + "executorgroup": "Группа", + "service": "Услуга", + "tasktype": "Тип заявки", + "initiator": "Инициатор", + "resolutiondateplan": "Плановый срок", + "closedat": "Закрыта", + "description": "Описание", + "name": "Название", + "workflow": "Бизнес-процесс", + "sla": "SLA", + "tags": "Теги", +} + +# Поля, изменение которых не выводится в уведомлении +IGNORED_FIELDS = { + "lifetime", "updatedat", "updatedby", "updatedchannel", + "reactiondatefact", "resolutiondatefact", "resolutionleftminutes", + "reactiondateplan", "ismassincidentactive", +} + + +def _parse_json_field(raw): + """Парсит JSON-строку или возвращает как есть.""" + if raw is None: + return None + if isinstance(raw, (dict, list)): + return raw + if isinstance(raw, str): + try: + return json.loads(raw) + except Exception: + return raw + return raw + + +def _scalar(raw): + """Возвращает скалярное значение поля Fields (Value или Id).""" + parsed = _parse_json_field(raw) + if isinstance(parsed, dict): + return parsed.get("Value") or parsed.get("Id") + return parsed + + +def _extract_name(v: dict | None, blockname: str = "") -> str | None: + """ + Достаёт человекочитаемое имя из объекта valuenew/valueold. + + Обрабатывает три варианта поля name: + 1. строка: {"id": 8452, "name": "Иванов"} + 2. объект {ru, en}: {"id": 96, "name": {"ru": "Выполнена", "en": null}} + 3. отсутствует/null: {"id": 68045, "name": null} → резольвинг через БД + """ + if not v: + return None + + raw_name = v.get("currentname") or v.get("name") + + # name — объект {"ru": ..., "en": ...} + if isinstance(raw_name, dict): + raw_name = raw_name.get("ru") or raw_name.get("en") or None + + if raw_name: + return str(raw_name).strip() or None + + # name отсутствует или null — резольвинг через БД/статик + item_id = v.get("id") + entity = BLOCK_TO_ENTITY.get(blockname) + if item_id and entity: + return reference.resolve(entity, item_id) or str(item_id) + + return str(item_id) if item_id else None + + +def get_executor_group(fields: dict) -> str | None: + """Группа исполнителей (Type=10 в taskusergroups).""" + raw = _parse_json_field(fields.get("taskusergroups", {})) + if not isinstance(raw, dict): + return None + for group in raw.get("TaskUserGroups", []): + if group.get("Type") == 10: + return group.get("Name", "").strip() or None + return None + + +def get_initiator(fields: dict) -> str | None: + """Инициатор заявки (Type=10 в taskusers).""" + raw = _parse_json_field(fields.get("taskusers", {})) + if not isinstance(raw, dict): + return None + for user in raw.get("TaskUsers", []): + if user.get("Type") == 10: + return user.get("Name", "").strip() or None + return None + + +def is_final(fields: dict) -> bool: + """Возвращает True если статус задачи финальный (закрыта).""" + raw = _parse_json_field(fields.get("status", {})) + if isinstance(raw, dict): + return bool(raw.get("IsFinal", False)) + return False + + +def parse_lifetime(fields: dict) -> tuple[list[str], list[str]]: + """ + Разбирает lifetime-поле вебхука. + + Возвращает: + changes — строки вида «• Поле: было → стало» + comments — строки вида «*Автор*: текст» + """ + changes: list[str] = [] + comments: list[str] = [] + + lifetime = _parse_json_field(fields.get("lifetime", "{}")) + if not isinstance(lifetime, dict): + return changes, comments + + for entry in lifetime.get("Data", []): + username = entry.get("username", "Неизвестный") + + for event in entry.get("events", {}).get("Data", []): + etype = event.get("type") + blockname = event.get("blockname", "") + + # ── Комментарии ──────────────────────────────────────────────── + if etype == 50: + text = event.get("stringvalue") + if text: + comments.append(f"*{username}*: {text}") + continue + + # Приватный комментарий (необязательно показывать, но пусть будет) + if etype == 55: + text = event.get("stringvalue") + if text: + comments.append(f"*{username}* 🔒: {text}") + continue + + if blockname in IGNORED_FIELDS: + continue + + label = FIELD_LABELS.get(blockname, reference.event_name(etype)) + + # ── Изменения с entityvalue / entitylanguagevalue ────────────── + entity_data = event.get("entityvalue") or event.get("entitylanguagevalue") + if entity_data: + val_new = entity_data.get("valuenew") or {} + val_old = entity_data.get("valueold") + + new_str = _extract_name(val_new, blockname) + old_str = _extract_name(val_old, blockname) if val_old else None + + if not new_str: + continue + + if old_str: + changes.append(f"• {label}: {old_str} → *{new_str}*") + else: + changes.append(f"• {label}: *{new_str}*") + continue + + # ── Строковые изменения (название, описание) ─────────────────── + str_val = event.get("stringvalue") + if str_val: + changes.append(f"• {label}: *{str_val[:80]}{'…' if len(str_val) > 80 else ''}*") + continue + + # ── Событие без значения (теги, файлы, эскалация) ───────────── + if etype not in (185, 250): # пропускаем служебные + changes.append(f"• {label}") + + return changes, comments + + +def _resolve_field(fields: dict, field: str, entity: str) -> str | None: + """Резолвит ID поля верхнего уровня через справочники.""" + raw = _parse_json_field(fields.get(field, {})) + if not isinstance(raw, dict): + return None + item_id = raw.get("Id") or raw.get("Value") + if not item_id: + return None + return reference.resolve(entity, item_id) or str(item_id) + + +def format_notification(payload: dict) -> str: + """Полная карточка задачи для основного поста в канале.""" + op_type = payload.get("Event", {}).get("OperationType", "") + fields = payload.get("Fields", {}) + + number = fields.get("Number", "?") + name = _scalar(fields.get("name")) or "—" + executor_grp = get_executor_group(fields) + initiator = get_initiator(fields) + + status = _resolve_field(fields, "status", "statuses") + priority = _resolve_field(fields, "priority", "priorities") + service = _resolve_field(fields, "service", "services") + tasktype = _resolve_field(fields, "tasktype", "tasktypes") + + changes, comments = parse_lifetime(fields) + + header = f"🆕 *Новая заявка \\#{number}*" if op_type == "Created" \ + else f"🔄 *Заявка \\#{number} обновлена*" + + lines = [header, name, ""] + + if status: + lines.append(f"📌 Статус: {status}") + if priority: + lines.append(f"🔺 Приоритет: {priority}") + if service: + lines.append(f"🗂 Услуга: {service}") + if tasktype: + lines.append(f"📋 Тип: {tasktype}") + if executor_grp: + lines.append(f"👥 Группа: {executor_grp}") + if initiator: + lines.append(f"👤 Инициатор: {initiator}") + if any([status, priority, service, tasktype, executor_grp, initiator]): + lines.append("") + + if changes: + lines.extend(changes) + lines.append("") + + if comments: + lines.append("💬 *Комментарии:*") + lines.extend(comments) + lines.append("") + + task_url = config.BASE_TASK_URL + str(number) + lines.append(f"[Открыть заявку]({task_url})") + + return "\n".join(lines) + + +def format_update_summary(changes: list[str], comments: list[str]) -> str | None: + """Короткий reply-пост при обновлении (триггерит пуш подписчикам канала).""" + if not changes and not comments: + return None + lines = ["🔄 *Изменено:*"] + lines.extend(changes) + if comments: + lines.append("💬 *Комментарии:*") + lines.extend(comments) + return "\n".join(lines) diff --git a/intradesk/handler.py b/intradesk/handler.py new file mode 100644 index 0000000..463f5a0 --- /dev/null +++ b/intradesk/handler.py @@ -0,0 +1,52 @@ +import logging +from datetime import datetime + +import events +from bot import telegram as bot +from intradesk import formatter + +log = logging.getLogger(__name__) + + +def handle_webhook(payload: dict): + try: + task_id = payload.get("Id") + op_type = payload.get("Event", {}).get("OperationType", "") + fields = payload.get("Fields", {}) + number = fields.get("Number", "?") + + if not task_id: + log.warning("Webhook payload missing Id — skipped") + return + + if op_type not in ("Created", "Updated"): + log.debug(f"OperationType '{op_type}' — skipped") + return + + changes, comments = formatter.parse_lifetime(fields) + full_text = formatter.format_notification(payload) + + events.broadcast({ + "type": "webhook", + "timestamp": datetime.utcnow().strftime("%d.%m.%Y %H:%M:%S"), + "task_id": task_id, + "task_number": number, + "operation": op_type, + "raw": payload, + "parsed": { + "changes": changes, + "comments": comments, + }, + "formatted": full_text, + }) + + closed = formatter.is_final(fields) + + if op_type == "Created": + bot.send_or_edit(task_id, number, full_text, closed=closed) + else: + summary = formatter.format_update_summary(changes, comments) + bot.send_or_edit(task_id, number, full_text, summary, closed=closed) + + except Exception: + log.exception("Error while handling webhook") diff --git a/intradesk/reference.py b/intradesk/reference.py new file mode 100644 index 0000000..1975122 --- /dev/null +++ b/intradesk/reference.py @@ -0,0 +1,278 @@ +""" +Справочники Intradesk. + +Содержит: + 1. Статические константы (известные ID → имена) + 2. Загрузку справочников из API Intradesk в локальную БД +""" + +import logging + +import requests + +import config +import database + +log = logging.getLogger(__name__) + +# ── Статические справочники ──────────────────────────────────────────────────── +# Используются как fallback если данные ещё не загружены из API, +# а также сидируются в БД при старте. + +STATUSES: dict[int, str] = { + 68046: "Открыта", + 68048: "Требует уточнения", + 68051: "В работе", + 68045: "Выполнена", +} + +PRIORITIES: dict[int, str] = { + 29749: "Наивысший", + 29598: "Критический", + 29597: "Высокий", + 29596: "Низкий", +} + +USER_TYPES: dict[int, str] = { + 10: "Клиент", + 20: "Сотрудник", +} + +# Все типы событий из lifecycle заявки +EVENT_TYPES: dict[int, str] = { + 10: "Смена статуса", + 20: "Смена приоритета", + 25: "Смена оценки", + 30: "Смена исполнителя", + 40: "Смена группы исполнителей", + 45: "Изменение доп. данных", + 50: "Комментарий", + 55: "Приватный комментарий", + 60: "Добавлен наблюдатель", + 61: "Добавлен координатор", + 65: "Добавлена группа наблюдателей", + 70: "Удалён наблюдатель", + 75: "Удалена группа наблюдателей", + 80: "Добавлен файл", + 85: "Добавлен скрытый файл", + 90: "Удалён файл", + 95: "Удалён скрытый файл", + 100: "Добавлен тег", + 105: "Добавлен актив", + 110: "Удалён тег", + 115: "Удалён актив", + 120: "Изменён плановый срок решения", + 125: "Изменён фактический срок решения", + 126: "Изменено оставшееся время", + 130: "Изменён плановый срок реакции", + 135: "Изменён фактический срок реакции", + 136: "Истёк срок решения", + 137: "Истёк срок реакции", + 138: "Изменена дата закрытия", + 140: "Изменён сервис", + 150: "Изменён тип заявки", + 160: "Изменён инициатор", + 161: "Изменён клиент", + 165: "Изменена группа инициатора", + 170: "Изменено доп. поле", + 180: "Изменена дата создания", + 185: "Изменена дата обновления", + 190: "Изменён создатель", + 195: "Изменён последний редактор", + 200: "Изменено название", + 205: "Изменено описание", + 210: "Изменён бизнес-процесс", + 220: "Добавлен не ответственный исполнитель", + 225: "Добавлена не ответственная группа исполнителей", + 230: "Удалён не ответственный исполнитель", + 235: "Удалена не ответственная группа исполнителей", + 240: "Изменён SLA", + 250: "Изменена история", + 260: "Изменён канал создания", + 270: "Изменён канал обновления", + 280: "Архивировано", + 290: "Добавлены координаты", + 300: "Выполнен макрос", + 310: "Изменён признак массового инцидента", + 320: "Изменён массовый инцидент", + 330: "Изменена координация", + 340: "Изменена критичность приоритета", + 350: "Изменено влияние приоритета", + 400: "Эскалация заявки", +} + +# ── API-справочники (загружаются из Intradesk) ───────────────────────────────── +# +# Статус эндпоинта: +# [OK] — проверено, работает +# [?] — нужно проверить, путь предположительный + +SETTINGS_URL = "https://apigw.intradesk.ru/settings/odata" + +ENTITIES = [ + { + "key": "employees", + "url": f"{SETTINGS_URL}/Employees", + "params": {"$filter": "isArchived eq false"}, + "id_field": "id", + "name_field": "name", + "status": "OK", + }, + { + "key": "groups", + "url": f"{SETTINGS_URL}/UserGroups", + "params": {"$filter": "isArchived eq false"}, + "id_field": "id", + "name_field": "name", + "status": "?", + }, + { + "key": "statuses", + "url": f"{SETTINGS_URL}/Statuses", + "params": {}, + "id_field": "id", + "name_field": "name", + "status": "?", + }, + { + "key": "priorities", + "url": f"{SETTINGS_URL}/Priorities", + "params": {}, + "id_field": "id", + "name_field": "name", + "status": "?", + }, + { + "key": "services", + "url": f"{SETTINGS_URL}/ServiceCatalog", + "params": {"$filter": "isArchived eq false"}, + "id_field": "id", + "name_field": "name", + "status": "?", + }, + { + "key": "tasktypes", + "url": f"{SETTINGS_URL}/TaskTypes", + "params": {"$filter": "isArchived eq false"}, + "id_field": "id", + "name_field": "name", + "status": "?", + }, + { + "key": "tags", + "url": f"{SETTINGS_URL}/Tags", + "params": {}, + "id_field": "id", + "name_field": "name", + "status": "?", + }, + { + "key": "workflows", + "url": f"{SETTINGS_URL}/Workflows", + "params": {"$filter": "isArchived eq false"}, + "id_field": "id", + "name_field": "name", + "status": "?", + }, + { + "key": "sla", + "url": f"{SETTINGS_URL}/Sla", + "params": {}, + "id_field": "id", + "name_field": "name", + "status": "?", + }, +] + + +# ── вспомогательные ──────────────────────────────────────────────────────────── + +def resolve(entity: str, item_id: int | None) -> str | None: + """ + Резолвит ID в имя. + Сначала смотрит в БД (актуальные данные из API), + затем в статические словари-fallback. + """ + if item_id is None: + return None + + # БД — приоритет + name = database.resolve(entity, item_id) + if name: + return name + + # Fallback на статические константы + _fallbacks = { + "statuses": STATUSES, + "priorities": PRIORITIES, + } + fb = _fallbacks.get(entity, {}) + return fb.get(item_id) + + +def event_name(event_type: int) -> str: + """Возвращает человекочитаемое имя события по его числовому коду.""" + return EVENT_TYPES.get(event_type, f"Событие {event_type}") + + +def user_type_name(user_type: int) -> str: + """Возвращает тип пользователя (Клиент / Сотрудник).""" + return USER_TYPES.get(user_type, str(user_type)) + + +# ── загрузка из API ──────────────────────────────────────────────────────────── + +def _fetch_all_pages(url: str, params: dict) -> list[dict]: + params = {**params, "ApiKey": config.API_KEY} + items = [] + current_url = url + + while current_url: + try: + resp = requests.get(current_url, params=params, timeout=30) + resp.raise_for_status() + except requests.exceptions.RequestException as e: + log.error(f"Request failed [{current_url}]: {e}") + break + + data = resp.json() + items.extend(data.get("value", [])) + current_url = data.get("@odata.nextLink") + params = None + + return items + + +def _load_entity(entity: dict) -> int: + key = entity["key"] + id_field = entity["id_field"] + name_field = entity["name_field"] + + items = _fetch_all_pages(entity["url"], entity.get("params", {})) + if not items: + log.warning(f"[{key}] empty or failed — skipped") + return 0 + + pairs = [ + (item[id_field], str(item.get(name_field) or "").strip()) + for item in items + if id_field in item + ] + database.save_ref_data(key, pairs) + log.info(f"[{key}] loaded {len(pairs)} records") + return len(pairs) + + +def seed_static(): + """Сидирует в БД известные статические данные (статусы, приоритеты).""" + database.save_ref_data("statuses", list(STATUSES.items())) + database.save_ref_data("priorities", list(PRIORITIES.items())) + log.info("Static reference data seeded (statuses, priorities)") + + +def load_all(): + """Загружает все справочники из API. Неудачные — логирует и пропускает.""" + log.info("Loading reference data from Intradesk API...") + for entity in ENTITIES: + _load_entity(entity) + log.info("Reference data loading complete.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1acab85 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests +pytelegrambotapi +flask +schedule diff --git a/tests/webhook.json b/tests/webhook.json new file mode 100644 index 0000000..fffda52 --- /dev/null +++ b/tests/webhook.json @@ -0,0 +1,370 @@ +{ + "Id": 5430092, + "Event": { + "EventType": "TaskEvents", + "OperationType": "Updated" + }, + "Fields": { + "aiemotionalassessment": { + "Value": null + }, + "aihistory": { + "Value": null + }, + "assets": [], + "attachments": [], + "commentemailmessageid": { + "Value": null + }, + "createdchannel": { + "Value": "web" + }, + "emailmessageid": { + "Value": null + }, + "evaluation": "null", + "massincident": null, + "prioritycriticality": { + "Id": null + }, + "priority": { + "Id": 29599 + }, + "priorityinfluence": { + "Id": null + }, + "privateattachments": [], + "service": { + "Id": 84683, + "Path": "84681/84683/" + }, + "tags": [], + "taskbranch": null, + "tasktype": { + "Id": 21026, + "Name": null + }, + "updatedchannel": { + "Value": "web" + }, + "workflow": { + "Value": 18090 + }, + "additionaldata": {}, + "clientid": { + "Value": null + }, + "olareactiondateplan": { + "Value": null + }, + "olaresolutiondateplan": { + "Value": null + }, + "sla": { + "Id": null + }, + "status": { + "Id": 68045, + "IsStartTimeTracking": false, + "IsStopTimeTracking": true, + "IsFinal": true, + "IsCompletion": true, + "IsCoordination": false + }, + "closedat": { + "Value": "2026-02-24T09:04:05.0756847Z" + }, + "ismassincidentactive": { + "Value": false + }, + "reactiondatefact": { + "Value": "2026-02-24T09:04:00.0000000Z" + }, + "reactiondateplan": { + "Value": null + }, + "resolutiondatefact": { + "Value": "2026-02-24T09:04:00.0000000Z" + }, + "isreactionexpired": { + "Value": null + }, + "resolutiondateplan": { + "Value": null + }, + "coordination": { + "Value": null + }, + "resolutionleftminutes": { + "Value": null + }, + "isresolutionexpired": { + "Value": false + }, + "prevstatus": { + "Id": 68051, + "IsStartTimeTracking": true, + "IsStopTimeTracking": false, + "IsFinal": false, + "IsCompletion": false, + "IsCoordination": false + }, + "taskusers": { + "TaskUsers": [ + { + "Id": 2227907, + "Name": "Высоких Дмитрий ", + "Type": 10 + }, + { + "Id": 2227907, + "Name": "Высоких Дмитрий ", + "Type": 20 + }, + { + "Id": 512585, + "Name": "Кравченко Дмитрий ", + "Type": 50 + } + ] + }, + "taskusergroups": { + "TaskUserGroups": [ + { + "Id": 243025, + "Name": "Поддержка, 1-ая линия", + "Type": 10 + } + ] + }, + "addfield_a20b51bvid_remontaSingleSelect": { + "TaskFieldType": 3, + "ValueType": 4, + "Value": null, + "IsAdditionalField": true + }, + "addfield_8d27394diagnostirovannaia_neispravnostMultiSelect": { + "TaskFieldType": 8, + "ValueType": 13, + "Value": null, + "IsAdditionalField": true + }, + "addfield_3d36364vid_remontaSingleSelect": { + "TaskFieldType": 3, + "ValueType": 4, + "Value": null, + "IsAdditionalField": true + }, + "addfield_21282a5testString": { + "TaskFieldType": 2, + "ValueType": 4, + "Value": null, + "IsAdditionalField": true + }, + "addfield_ea5d305sozvon_pered_vyezdomBoolean": { + "TaskFieldType": 5, + "ValueType": 12, + "Value": null, + "IsAdditionalField": true + }, + "addfield_677b45atoBoolean": { + "TaskFieldType": 5, + "ValueType": 12, + "Value": null, + "IsAdditionalField": true + }, + "addfield_71c50d0obieektSingleSelect": { + "TaskFieldType": 3, + "ValueType": 4, + "Value": null, + "IsAdditionalField": true + }, + "addfield_2684bdczamena_oborudovaniiaBoolean": { + "TaskFieldType": 5, + "ValueType": 12, + "Value": null, + "IsAdditionalField": true + }, + "addfield_764142dzameniaemoe_oborudovanieMultiSelect": { + "TaskFieldType": 8, + "ValueType": 13, + "Value": null, + "IsAdditionalField": true + }, + "lifetime": { + "Data": [ + { + "lifetimetype": 20, + "userid": 2227907, + "username": "Высоких Дмитрий ", + "authortaskusertypes": [ + 10, + 20 + ], + "usertype": 10, + "eventat": "2026-02-24T09:04:05.1774367Z", + "events": { + "Data": [ + { + "isautomatic": false, + "blockname": "comment", + "type": 50, + "commentupdatedate": null, + "stringvalue": "2", + "datevalue": null, + "entityvalue": null, + "entitylanguagevalue": null, + "additionalfieldvalue": null + }, + { + "isautomatic": false, + "blockname": "status", + "type": 10, + "commentupdatedate": null, + "stringvalue": null, + "datevalue": null, + "entityvalue": null, + "entitylanguagevalue": { + "valuenew": { + "id": 68045, + "name": null, + "currentname": null + }, + "valueold": { + "id": 68051, + "name": null, + "currentname": null + } + }, + "additionalfieldvalue": null + } + ], + "Count": 2 + } + } + ], + "Count": 1 + }, + "createdat": { + "Value": "2026-02-24T09:03:21.8840000Z" + }, + "createdby": { + "Value": "Высоких Дмитрий " + }, + "description": { + "Value": "123" + }, + "name": { + "Value": "test" + }, + "updatedat": { + "Value": "2026-02-24T09:04:05.1780000Z" + }, + "updatedby": { + "Value": "Высоких Дмитрий " + }, + "Events": [ + { + "EventType": 50, + "Block": "comment", + "ValueType": 4, + "OldValue": "null", + "NewValue": "\"2\"" + }, + { + "EventType": 10, + "Block": "status", + "ValueType": 10, + "OldValue": { + "Id": 68051 + }, + "NewValue": { + "Id": 68045 + } + }, + { + "EventType": 138, + "Block": "closedat", + "ValueType": 6, + "OldValue": "null", + "NewValue": "\"2026-02-24T09:04:05.0756847Z\"" + }, + { + "EventType": 125, + "Block": "resolutiondatefact", + "ValueType": 6, + "OldValue": "null", + "NewValue": "\"2026-02-24T09:04:00.0000000Z\"" + }, + { + "EventType": 250, + "Block": "lifetime", + "ValueType": 16, + "OldValue": null, + "NewValue": { + "Data": [ + { + "lifetimetype": 20, + "userid": 2227907, + "username": "Высоких Дмитрий ", + "authortaskusertypes": [ + 10, + 20 + ], + "usertype": 10, + "eventat": "2026-02-24T09:04:05.1774367Z", + "events": { + "Data": [ + { + "isautomatic": false, + "blockname": "comment", + "type": 50, + "commentupdatedate": null, + "stringvalue": "2", + "datevalue": null, + "entityvalue": null, + "entitylanguagevalue": null, + "additionalfieldvalue": null + }, + { + "isautomatic": false, + "blockname": "status", + "type": 10, + "commentupdatedate": null, + "stringvalue": null, + "datevalue": null, + "entityvalue": null, + "entitylanguagevalue": { + "valuenew": { + "id": 68045, + "name": null, + "currentname": null + }, + "valueold": { + "id": 68051, + "name": null, + "currentname": null + } + }, + "additionalfieldvalue": null + } + ], + "Count": 2 + } + } + ], + "Count": 1 + } + }, + { + "EventType": 185, + "Block": "updatedat", + "ValueType": 6, + "OldValue": "\"2026-02-24T09:04:00.8840000Z\"", + "NewValue": "\"2026-02-24T09:04:05.1780000Z\"" + } + ], + "Number": "4687" + }, + "AuthorId": 2227907 +} \ No newline at end of file diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/server.py b/web/server.py new file mode 100644 index 0000000..d6c8fc1 --- /dev/null +++ b/web/server.py @@ -0,0 +1,153 @@ +import json +import logging +import os +import threading + +from flask import Flask, Response, jsonify, render_template, request, stream_with_context + +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 от 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 + + threading.Thread(target=handle_webhook, args=(data,), daemon=True).start() + return jsonify({"ok": True}), 200 + + +@app.route("/health") +def health(): + return jsonify({"status": "ok"}), 200 + + +# ── Дашборд ──────────────────────────────────────────────────────────────────── + +@app.route("/") +def dashboard(): + return render_template("dashboard.html") + + +@app.route("/stream") +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") +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") +def api_tasks_active(): + filter_expr = request.args.get( + "filter", "(status ne 68045) and (executorgroup eq 243025)" + ) + tasks = intradesk_api.get_tasks(filter_expr, top=50) + return jsonify(tasks) + + +@app.route("/api/task/lifetime") +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/") +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}) + + +def start_server(): + 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 + dashboard → https://0.0.0.0:{config.PORT} (SSL)") + 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 + dashboard → http://0.0.0.0:{config.PORT} (no SSL)") + app.run(host="0.0.0.0", port=config.PORT, use_reloader=False) diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..112b3b9 --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,689 @@ + + + + + +IntradBot Dashboard + + + + + + +
+ + +
+
+

Вебхуки в реальном времени

+
+ +
+
+
+
+
Ожидание вебхуков…
+
+
+
+ + +
+
+

Заявки

+
+ +
+
+
+
+ + +
+
+
Введите номер заявки или загрузите активные
+
+
+
+ + +
+
+

API — изменение заявки

+
+
+
+ + +
+
Параметры
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
Запрос будет отправлен
+
+
Нажмите «Предпросмотр запроса»
+
+ + +
+
+
+
+ +
+
+
+ +
+ + + +