intrabot/intradesk/formatter.py
2026-02-24 15:15:23 +03:00

275 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

import json
import logging
import 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)