This commit is contained in:
caps 2026-02-24 15:15:23 +03:00
commit fe02b5139a
20 changed files with 2486 additions and 0 deletions

7
.env.example Normal file
View file

@ -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

12
Dockerfile Normal file
View file

@ -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"]

0
bot/__init__.py Normal file
View file

186
bot/telegram.py Normal file
View file

@ -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()

9
config.py Normal file
View file

@ -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"))

0
data/.gitkeep Normal file
View file

84
database.py Normal file
View file

@ -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]

9
docker-compose.yml Normal file
View file

@ -0,0 +1,9 @@
services:
intradbot:
build: .
restart: always
env_file: .env
ports:
- "8080:8080"
volumes:
- ./data:/app/data # БД и логи хранятся вне контейнера

50
events.py Normal file
View file

@ -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)

46
intradbot.py Normal file
View file

@ -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()

0
intradesk/__init__.py Normal file
View file

262
intradesk/api.py Normal file
View file

@ -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)})

275
intradesk/formatter.py Normal file
View file

@ -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)

52
intradesk/handler.py Normal file
View file

@ -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")

278
intradesk/reference.py Normal file
View file

@ -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.")

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
requests
pytelegrambotapi
flask
schedule

370
tests/webhook.json Normal file
View file

@ -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
}

0
web/__init__.py Normal file
View file

153
web/server.py Normal file
View file

@ -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/<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})
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)

View file

@ -0,0 +1,689 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IntradBot Dashboard</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d1117;--surface:#161b22;--surface2:#21262d;--border:#30363d;
--accent:#388bfd;--accent-h:#58a6ff;--success:#3fb950;--warn:#d29922;
--danger:#da3633;--text:#c9d1d9;--muted:#8b949e;--r:6px;
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
--mono:'SFMono-Regular',Consolas,monospace;
}
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;display:flex;height:100vh;overflow:hidden}
/* ── Sidebar ── */
.sidebar{width:220px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
.brand{padding:18px 16px 16px;display:flex;align-items:center;gap:8px;font-size:15px;font-weight:700;border-bottom:1px solid var(--border)}
.nav{padding:8px 0;flex:1}
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 16px;cursor:pointer;color:var(--muted);border-left:3px solid transparent;font-size:13px;transition:.15s}
.nav-item:hover{background:var(--surface2);color:var(--text)}
.nav-item.active{color:var(--accent-h);border-left-color:var(--accent);background:rgba(56,139,253,.1)}
.nav-icon{width:18px;text-align:center}
.sidebar-foot{padding:14px 16px;border-top:1px solid var(--border)}
.status-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
.dot.on{background:var(--success);box-shadow:0 0 6px var(--success)}
.dot.off{background:var(--danger)}
.counter{margin-left:auto;background:var(--accent);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:600;display:none}
.counter.show{display:inline}
/* ── Main ── */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.tab{display:none;flex:1;flex-direction:column;overflow:hidden}
.tab.active{display:flex}
.tab-head{padding:14px 24px;border-bottom:1px solid var(--border);background:var(--surface);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.tab-head h2{font-size:15px;font-weight:600}
.tab-body{flex:1;overflow-y:auto;padding:16px 24px}
/* ── Buttons ── */
.btn{padding:6px 14px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:var(--r);cursor:pointer;font-size:13px;font-family:var(--font);transition:.15s;display:inline-flex;align-items:center;gap:6px}
.btn:hover{border-color:var(--accent);color:var(--accent-h)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent-h);border-color:var(--accent-h);color:#fff}
.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff}
.btn-danger:hover{opacity:.85;color:#fff}
.btn-sm{padding:4px 10px;font-size:12px}
.btn-group{display:flex;gap:8px}
/* ── Inputs ── */
input,select,textarea{background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:var(--r);font-family:var(--font);font-size:13px;width:100%;transition:.15s}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}
textarea{resize:vertical;min-height:80px}
select option{background:var(--surface2)}
.form-row{display:flex;gap:8px;margin-bottom:12px}
.form-row input,.form-row select{flex:1}
.form-group{margin-bottom:14px}
.form-label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:5px}
/* ── Webhook cards ── */
.feed{display:flex;flex-direction:column;gap:10px}
.wcard{border:1px solid var(--border);border-radius:var(--r);overflow:hidden;background:var(--surface)}
.wcard.new{border-left:3px solid var(--success)}
.wcard.updated{border-left:3px solid var(--accent)}
.wcard-head{padding:12px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none}
.wcard-head:hover{background:var(--surface2)}
.badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;text-transform:uppercase}
.badge-new{background:rgba(63,185,80,.15);color:var(--success)}
.badge-upd{background:rgba(56,139,253,.15);color:var(--accent-h)}
.wcard-num{font-weight:700;font-size:14px}
.wcard-name{color:var(--muted);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wcard-time{color:var(--muted);font-size:12px;flex-shrink:0}
.chevron{color:var(--muted);font-size:11px;transition:.2s;flex-shrink:0}
.wcard.open .chevron{transform:rotate(180deg)}
.wcard-body{display:none;border-top:1px solid var(--border)}
.wcard.open .wcard-body{display:block}
.wsec{border-bottom:1px solid var(--border)}
.wsec:last-child{border-bottom:none}
.wsec-head{padding:8px 16px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:6px;background:rgba(255,255,255,.02)}
.wsec-head:hover{color:var(--text)}
.wsec-body{padding:12px 16px;display:none}
.wsec.open .wsec-body{display:block}
.wsec.open .wsec-chevron{transform:rotate(90deg)}
.wsec-chevron{transition:.15s}
.change-item{padding:4px 0;border-bottom:1px solid rgba(255,255,255,.04);font-size:13px}
.change-item:last-child{border-bottom:none}
.comment-item{background:rgba(56,139,253,.06);border-left:2px solid var(--accent);padding:6px 10px;margin:3px 0;border-radius:0 var(--r) var(--r) 0;font-size:13px}
.formatted-msg{font-family:var(--mono);font-size:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:12px;white-space:pre-wrap;word-break:break-all;max-height:300px;overflow-y:auto}
/* ── Tasks ── */
.task-card{border:1px solid var(--border);border-radius:var(--r);padding:20px;background:var(--surface);margin-bottom:12px;cursor:pointer;transition:.15s}
.task-card:hover{border-color:var(--accent)}
.task-card.expanded{border-color:var(--accent)}
.task-title{font-size:15px;font-weight:600;margin-bottom:10px;display:flex;align-items:flex-start;gap:10px}
.task-num{color:var(--muted);font-size:13px;font-weight:400;flex-shrink:0}
.task-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;margin-bottom:12px}
.meta-item{background:var(--surface2);border-radius:var(--r);padding:10px 12px}
.meta-label{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:3px}
.meta-val{font-size:13px;font-weight:500}
.tasks-list{display:flex;flex-direction:column;gap:8px}
.status-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px}
.s-open{background:rgba(63,185,80,.15);color:var(--success)}
.s-work{background:rgba(56,139,253,.15);color:var(--accent-h)}
.s-done{background:rgba(139,148,158,.15);color:var(--muted)}
.s-wait{background:rgba(210,153,34,.15);color:var(--warn)}
.task-desc{font-size:13px;color:var(--muted);padding:10px;background:var(--bg);border-radius:var(--r);margin-top:8px;white-space:pre-wrap}
/* ── API form ── */
.api-layout{display:grid;grid-template-columns:360px 1fr;gap:20px;align-items:start}
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:20px}
.panel-title{font-size:13px;font-weight:600;margin-bottom:16px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
.preview-url{font-family:var(--mono);font-size:12px;padding:8px 10px;background:var(--bg);border-radius:var(--r);margin-bottom:10px;color:var(--warn);word-break:break-all}
pre{font-family:var(--mono);font-size:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:12px;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
.result-block{margin-top:12px;padding:10px 14px;border-radius:var(--r);font-size:13px}
.result-ok{background:rgba(63,185,80,.1);border:1px solid var(--success);color:var(--success)}
.result-err{background:rgba(218,54,51,.1);border:1px solid var(--danger);color:var(--danger)}
.result-info{background:rgba(56,139,253,.1);border:1px solid var(--accent);color:var(--accent-h)}
.hidden{display:none!important}
.empty{text-align:center;color:var(--muted);padding:60px 20px;font-size:14px}
.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--muted)}
</style>
</head>
<body>
<aside class="sidebar">
<div class="brand">🤖 IntradBot</div>
<nav class="nav">
<div class="nav-item active" data-tab="webhooks" onclick="switchTab('webhooks',this)">
<span class="nav-icon">🔔</span> Вебхуки
<span class="counter" id="wh-counter">0</span>
</div>
<div class="nav-item" data-tab="tasks" onclick="switchTab('tasks',this)">
<span class="nav-icon">📋</span> Заявки
</div>
<div class="nav-item" data-tab="api" onclick="switchTab('api',this)">
<span class="nav-icon">⚙️</span> API
</div>
</nav>
<div class="sidebar-foot">
<div class="status-row">
<div class="dot" id="dot"></div>
<span id="dot-label">Подключение...</span>
</div>
</div>
</aside>
<main class="main">
<!-- ── Вебхуки ── -->
<section class="tab active" id="tab-webhooks">
<div class="tab-head">
<h2>Вебхуки в реальном времени</h2>
<div class="btn-group">
<button class="btn btn-sm" onclick="clearFeed()">Очистить</button>
</div>
</div>
<div class="tab-body">
<div class="feed" id="feed">
<div class="empty" id="feed-empty">Ожидание вебхуков…</div>
</div>
</div>
</section>
<!-- ── Заявки ── -->
<section class="tab" id="tab-tasks">
<div class="tab-head">
<h2>Заявки</h2>
<div class="btn-group">
<button class="btn btn-sm btn-primary" onclick="loadActive()">Загрузить активные</button>
</div>
</div>
<div class="tab-body">
<div class="form-row" style="margin-bottom:16px">
<input type="number" id="search-num" placeholder="Номер заявки" onkeydown="if(event.key==='Enter')searchTask()">
<button class="btn btn-primary" onclick="searchTask()" style="width:auto;flex-shrink:0">Найти</button>
</div>
<div id="tasks-result">
<div class="empty">Введите номер заявки или загрузите активные</div>
</div>
</div>
</section>
<!-- ── API ── -->
<section class="tab" id="tab-api">
<div class="tab-head">
<h2>API — изменение заявки</h2>
</div>
<div class="tab-body">
<div class="api-layout">
<!-- Форма -->
<div class="panel">
<div class="panel-title">Параметры</div>
<div class="form-group">
<label class="form-label">Номер заявки</label>
<input type="number" id="api-num" placeholder="272">
</div>
<div class="form-group">
<label class="form-label">Действие</label>
<select id="api-action" onchange="renderActionFields()">
<option value="status">Сменить статус</option>
<option value="comment">Добавить комментарий</option>
<option value="executor">Назначить исполнителя</option>
<option value="priority">Изменить приоритет</option>
<option value="resolution_date">Плановый срок</option>
<option value="tags">Теги (через запятую)</option>
<option value="custom">Произвольный блок JSON</option>
</select>
</div>
<div id="action-fields"></div>
<button class="btn btn-primary" style="width:100%" onclick="previewRequest()">
Предпросмотр запроса →
</button>
</div>
<!-- Предпросмотр + результат -->
<div>
<div class="panel" id="preview-panel" style="margin-bottom:16px">
<div class="panel-title">Запрос будет отправлен</div>
<div class="preview-url" id="preview-url"></div>
<pre id="preview-json">Нажмите «Предпросмотр запроса»</pre>
<div class="btn-group" style="margin-top:14px">
<button class="btn btn-danger" id="exec-btn" onclick="executeRequest()" disabled>
✓ Выполнить
</button>
<button class="btn" onclick="resetPreview()">Отмена</button>
</div>
</div>
<div id="api-result"></div>
</div>
</div>
</div>
</section>
</main>
<script>
// ═══════════════════════════════════════════════════════════════
// GLOBALS
// ═══════════════════════════════════════════════════════════════
let whCount = 0;
let _pendingBlocks = null;
const STATUSES = {
68046:'Открыта', 68048:'Требует уточнения',
68051:'В работе', 68045:'Выполнена'
};
const PRIORITIES = {
29749:'Наивысший', 29598:'Критический',
29597:'Высокий', 29596:'Низкий'
};
// ═══════════════════════════════════════════════════════════════
// TABS
// ═══════════════════════════════════════════════════════════════
function switchTab(id, el) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('tab-' + id).classList.add('active');
el.classList.add('active');
if (id === 'webhooks') {
whCount = 0;
const c = document.getElementById('wh-counter');
c.textContent = '0'; c.classList.remove('show');
}
if (id === 'api') renderActionFields();
}
// ═══════════════════════════════════════════════════════════════
// SSE — вебхуки в реальном времени
// ═══════════════════════════════════════════════════════════════
const es = new EventSource('/stream');
es.onopen = () => setStatus(true);
es.onerror = () => setStatus(false);
es.onmessage = e => {
const d = JSON.parse(e.data);
if (d.type === 'ping') return;
if (d.type === 'webhook') addWebhookCard(d);
};
function setStatus(on) {
document.getElementById('dot').className = 'dot ' + (on ? 'on' : 'off');
document.getElementById('dot-label').textContent = on ? 'Подключено' : 'Нет связи';
}
function addWebhookCard(d) {
const feed = document.getElementById('feed');
document.getElementById('feed-empty')?.remove();
const isNew = d.operation === 'Created';
const cls = isNew ? 'new' : 'updated';
const badge = isNew
? '<span class="badge badge-new">Новая</span>'
: '<span class="badge badge-upd">Обновлена</span>';
const changes = (d.parsed?.changes || []);
const comments = (d.parsed?.comments || []);
// Секция: Изменения
let changesHtml = '';
if (changes.length || comments.length) {
const items = [
...changes.map(c => `<div class="change-item">${esc(c)}</div>`),
...comments.map(c => `<div class="comment-item">${esc(c)}</div>`),
].join('');
changesHtml = makeSec('📝 Изменения / комментарии', items, true);
}
// Секция: Форматированное сообщение
const fmtHtml = makeSec('💬 Сообщение в канале',
`<div class="formatted-msg">${esc(d.formatted || '')}</div>`);
// Секция: Сырые данные
const rawHtml = makeSec('{ } Сырые данные',
`<pre>${esc(JSON.stringify(d.raw, null, 2))}</pre>`);
const card = document.createElement('div');
card.className = `wcard ${cls}`;
card.innerHTML = `
<div class="wcard-head" onclick="toggleCard(this)">
${badge}
<span class="wcard-num">#${esc(d.task_number)}</span>
<span class="wcard-name">${esc(getTaskName(d.raw))}</span>
<span class="wcard-time">${esc(d.timestamp)}</span>
<span class="chevron"></span>
</div>
<div class="wcard-body">
${changesHtml}
${fmtHtml}
${rawHtml}
</div>`;
feed.insertBefore(card, feed.firstChild);
// Счётчик на вкладке
if (!document.querySelector('.nav-item[data-tab="webhooks"]').classList.contains('active')) {
whCount++;
const c = document.getElementById('wh-counter');
c.textContent = whCount; c.classList.add('show');
}
}
function makeSec(title, body, openByDefault = false) {
const cls = openByDefault ? 'wsec open' : 'wsec';
return `
<div class="${cls}">
<div class="wsec-head" onclick="toggleSec(this)">
<span class="wsec-chevron"></span> ${title}
</div>
<div class="wsec-body">${body}</div>
</div>`;
}
function toggleCard(head) {
head.closest('.wcard').classList.toggle('open');
}
function toggleSec(head) {
head.closest('.wsec').classList.toggle('open');
}
function getTaskName(raw) {
try {
const n = raw.Fields?.name;
if (typeof n === 'object') return n.Value || '';
if (typeof n === 'string') return JSON.parse(n).Value || '';
} catch {}
return '';
}
function clearFeed() {
const feed = document.getElementById('feed');
feed.innerHTML = '<div class="empty" id="feed-empty">Ожидание вебхуков…</div>';
whCount = 0;
const c = document.getElementById('wh-counter');
c.textContent = '0'; c.classList.remove('show');
}
// ═══════════════════════════════════════════════════════════════
// ЗАЯВКИ
// ═══════════════════════════════════════════════════════════════
async function searchTask() {
const num = document.getElementById('search-num').value.trim();
if (!num) return;
const el = document.getElementById('tasks-result');
el.innerHTML = '<div class="empty"><span class="spin"></span> Загрузка…</div>';
try {
const r = await fetch(`/api/tasks/search?number=${num}`);
const data = await r.json();
if (data.error) { el.innerHTML = `<div class="empty">${esc(data.error)}</div>`; return; }
el.innerHTML = renderTaskCard(data);
} catch(e) {
el.innerHTML = `<div class="empty">Ошибка: ${esc(e.message)}</div>`;
}
}
async function loadActive() {
const el = document.getElementById('tasks-result');
el.innerHTML = '<div class="empty"><span class="spin"></span> Загрузка активных заявок…</div>';
try {
const r = await fetch('/api/tasks/active');
const tasks = await r.json();
if (!tasks.length) { el.innerHTML = '<div class="empty">Активных заявок нет</div>'; return; }
el.innerHTML = `<div style="margin-bottom:8px;color:var(--muted);font-size:12px">Найдено: ${tasks.length}</div>` +
'<div class="tasks-list">' + tasks.map(t => renderTaskCard(t)).join('') + '</div>';
} catch(e) {
el.innerHTML = `<div class="empty">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderTaskCard(t) {
const statusId = t.status;
const statusName= resolveId(STATUSES, statusId, 'statuses');
const priId = t.priority;
const priName = resolveId(PRIORITIES, priId, 'priorities');
const sCls = statusCls(statusId);
const meta = [
['Статус', `<span class="status-badge ${sCls}">${esc(statusName)}</span>`],
['Приоритет', esc(priName)],
['Исполнитель',esc(t.executorname || t.executor || '—')],
['Группа', esc(t.executorgroupname || t.executorgroup || '—')],
['Сервис', esc(t.servicename || t.service || '—')],
['Тип', esc(t.tasktypename || t.tasktype || '—')],
['Создана', esc(fmtDate(t.createdat))],
['Обновлена', esc(fmtDate(t.updatedat))],
['Создатель', esc(t.createdby || '—')],
['Плановый срок', esc(fmtDate(t.resolutiondateplan))],
].map(([l,v]) => `<div class="meta-item"><div class="meta-label">${l}</div><div class="meta-val">${v}</div></div>`).join('');
const desc = t.description
? `<div class="task-desc">${esc(t.description)}</div>` : '';
return `
<div class="task-card">
<div class="task-title">
<span class="task-num">#${t.tasknumber}</span>
${esc(t.name || '—')}
</div>
<div class="task-grid">${meta}</div>
${desc}
<div style="margin-top:10px">
<button class="btn btn-sm" onclick="fillApiForm(${t.tasknumber})">⚙️ Изменить через API</button>
<a href="https://cleverparking.intradesk.ru/task/list/${t.tasknumber}" target="_blank">
<button class="btn btn-sm" style="margin-left:6px">🔗 Открыть в системе</button>
</a>
</div>
</div>`;
}
function fillApiForm(num) {
switchTab('api', document.querySelector('.nav-item[data-tab="api"]'));
document.getElementById('api-num').value = num;
}
function statusCls(id) {
if (id === 68046) return 's-open';
if (id === 68051) return 's-work';
if (id === 68045) return 's-done';
if (id === 68048) return 's-wait';
return '';
}
function resolveId(dict, id, entity) {
if (dict[id]) return dict[id];
return id ? String(id) : '—';
}
function fmtDate(s) {
if (!s) return '—';
try {
return new Date(s).toLocaleString('ru-RU', {
day:'2-digit', month:'2-digit', year:'numeric',
hour:'2-digit', minute:'2-digit'
});
} catch { return s; }
}
// ═══════════════════════════════════════════════════════════════
// API — форма изменения заявки
// ═══════════════════════════════════════════════════════════════
async function renderActionFields() {
const action = document.getElementById('api-action').value;
const el = document.getElementById('action-fields');
resetPreview();
if (action === 'status') {
let opts = Object.entries(STATUSES).map(([id,n]) =>
`<option value="${id}">${n}</option>`).join('');
try {
const r = await fetch('/api/ref/statuses');
const items = await r.json();
if (items.length) opts = items.map(i => `<option value="${i.id}">${esc(i.name)}</option>`).join('');
} catch {}
el.innerHTML = `
<div class="form-group">
<label class="form-label">Новый статус</label>
<select id="f-status">${opts}</select>
</div>`;
}
else if (action === 'comment') {
el.innerHTML = `
<div class="form-group">
<label class="form-label">Текст комментария</label>
<textarea id="f-comment" placeholder="Принято в работу…"></textarea>
</div>`;
}
else if (action === 'executor') {
el.innerHTML = `
<div class="form-group">
<label class="form-label">ID группы (groupid)</label>
<input type="number" id="f-groupid" placeholder="243025">
</div>
<div class="form-group">
<label class="form-label">ID сотрудника (userid, необязательно)</label>
<input type="number" id="f-userid" placeholder="109747">
</div>`;
}
else if (action === 'priority') {
let opts = Object.entries(PRIORITIES).map(([id,n]) =>
`<option value="${id}">${n}</option>`).join('');
try {
const r = await fetch('/api/ref/priorities');
const items = await r.json();
if (items.length) opts = items.map(i => `<option value="${i.id}">${esc(i.name)}</option>`).join('');
} catch {}
el.innerHTML = `
<div class="form-group">
<label class="form-label">Приоритет</label>
<select id="f-priority">${opts}</select>
</div>`;
}
else if (action === 'resolution_date') {
const now = new Date();
now.setDate(now.getDate() + 1);
const iso = now.toISOString().slice(0,16);
el.innerHTML = `
<div class="form-group">
<label class="form-label">Дата и время (локальное)</label>
<input type="datetime-local" id="f-date" value="${iso}">
</div>`;
}
else if (action === 'tags') {
el.innerHTML = `
<div class="form-group">
<label class="form-label">ID тегов через запятую</label>
<input id="f-tags" placeholder="32887, 32893">
</div>`;
}
else if (action === 'custom') {
el.innerHTML = `
<div class="form-group">
<label class="form-label">Название блока</label>
<input id="f-block-name" placeholder="status">
</div>
<div class="form-group">
<label class="form-label">Значение (JSON)</label>
<textarea id="f-block-val" placeholder='{"value": 68051}'></textarea>
</div>`;
}
}
function buildBlocks() {
const action = document.getElementById('api-action').value;
const jv = v => JSON.stringify({value: v});
if (action === 'status')
return {status: jv(parseInt(document.getElementById('f-status').value))};
if (action === 'comment')
return {comment: jv(document.getElementById('f-comment').value)};
if (action === 'executor') {
const v = {};
const gid = document.getElementById('f-groupid').value;
const uid = document.getElementById('f-userid').value;
if (gid) v.groupid = parseInt(gid);
if (uid) v.userid = parseInt(uid);
return {executor: jv(v)};
}
if (action === 'priority')
return {priority: jv(parseInt(document.getElementById('f-priority').value))};
if (action === 'resolution_date') {
const dt = new Date(document.getElementById('f-date').value);
return {resolutiondateplan: jv(dt.toISOString())};
}
if (action === 'tags') {
const ids = document.getElementById('f-tags').value
.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n));
return {tags: jv(ids)};
}
if (action === 'custom') {
const name = document.getElementById('f-block-name').value.trim();
const val = document.getElementById('f-block-val').value.trim();
if (!name) return null;
return {[name]: val};
}
return null;
}
async function previewRequest() {
const num = parseInt(document.getElementById('api-num').value);
if (!num) { showResult('error', 'Укажите номер заявки'); return; }
const blocks = buildBlocks();
if (!blocks) { showResult('error', 'Заполните поля действия'); return; }
try {
const r = await fetch('/api/task/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({number: num, blocks}),
});
const d = await r.json();
document.getElementById('preview-url').textContent = `${d.method} ${d.url}`;
document.getElementById('preview-json').textContent = JSON.stringify(d.body, null, 2);
document.getElementById('exec-btn').disabled = false;
document.getElementById('api-result').innerHTML = '';
_pendingBlocks = blocks;
} catch(e) {
showResult('error', e.message);
}
}
async function executeRequest() {
if (!_pendingBlocks) return;
const num = parseInt(document.getElementById('api-num').value);
const btn = document.getElementById('exec-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spin"></span> Выполняется…';
try {
const r = await fetch('/api/task/execute', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({number: num, blocks: _pendingBlocks}),
});
const d = await r.json();
if (d.success) {
showResult('ok', `✓ Заявка #${num} успешно изменена`);
} else {
showResult('error', `Ошибка: ${d.error || 'неизвестная ошибка'}`);
}
} catch(e) {
showResult('error', e.message);
}
btn.innerHTML = '✓ Выполнить';
btn.disabled = true;
_pendingBlocks = null;
}
function resetPreview() {
document.getElementById('preview-url').textContent = '—';
document.getElementById('preview-json').textContent = 'Нажмите «Предпросмотр запроса»';
document.getElementById('exec-btn').disabled = true;
document.getElementById('api-result').innerHTML = '';
_pendingBlocks = null;
}
function showResult(type, msg) {
const cls = type === 'ok' ? 'result-ok' : type === 'error' ? 'result-err' : 'result-info';
document.getElementById('api-result').innerHTML =
`<div class="result-block ${cls}">${esc(msg)}</div>`;
}
// ═══════════════════════════════════════════════════════════════
// UTILS
// ═══════════════════════════════════════════════════════════════
function esc(s) {
return String(s ?? '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
// Инициализация формы API при загрузке
renderActionFields();
</script>
</body>
</html>