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)