intra_max_chatbot/external/intradesk_api.py
2026-03-26 16:16:39 +03:00

389 lines
14 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.

"""
Intradesk REST API: создание заявок и чтение истории/комментариев.
Эндпоинты:
POST /changes/v3/tasks — создать заявку
GET /taskhistory/api/v2.0/lifetime/{id}/full — история (комментарии, смена статуса...)
"""
from __future__ import annotations
import json
import logging
import re
import httpx
import config
logger = logging.getLogger(__name__)
_TAG_RE = re.compile(r"<[^>]+>")
def _strip_html(text: str) -> str:
"""Убрать HTML-теги и лишние пробелы из текста комментария."""
if not text:
return text
cleaned = _TAG_RE.sub("", text)
# Схлопнуть множественные пробелы/переносы
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
return cleaned
async def get_task_last_comment(task_id: int) -> dict | None:
"""
Получить последний комментарий к заявке из Intradesk.
Возвращает dict: {text, author, date} или None если нет комментариев.
"""
key = config.INTRADESK_TASKS_API_KEY
if not key:
return None
url = (
f"{config.INTRADESK_BASE_URL.rstrip('/')}"
f"/taskhistory/api/v2.0/lifetime/{task_id}/full"
f"?ApiKey={key}&sortDirection=Desc&top=10&events=50"
)
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=15.0)
if resp.status_code != 200:
logger.warning(
"get_task_last_comment task=%s → HTTP %s", task_id, resp.status_code
)
return None
data = resp.json()
except Exception as exc:
logger.warning("get_task_last_comment error: %s", exc)
return None
# Ищем последнее событие с blockname="comment" (type=50)
for entry in data.get("data") or []:
for ev in (entry.get("events") or {}).get("data") or []:
if ev.get("blockname") == "comment" and ev.get("type") == 50:
raw_date = entry.get("eventat") or ""
# "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54"
short_date = raw_date[:16].replace("T", " ")
return {
"text": _strip_html(ev.get("stringvalue") or ""),
"author": entry.get("username") or "",
"date": short_date,
}
return None
async def count_client_open_tasks(client_id: int) -> int:
"""Количество незакрытых заявок клиента через API.
Intradesk не возвращает @odata.count при $top=0, поэтому берём $top=1
— count в ответе отражает полный итог, а не размер страницы.
"""
key = config.INTRADESK_TASKS_API_KEY
if not key:
return 0
url = (
f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks"
f"?ApiKey={key}"
f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)"
f"&$top=1&$count=true"
)
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10.0)
if resp.status_code != 200:
return 0
data = resp.json()
# @odata.count — полный итог по фильтру, не зависит от $top
count = data.get("@odata.count")
if count is not None:
return int(count)
# Fallback: если count не вернулся — считаем по размеру value
return len(data.get("value") or [])
except Exception:
return 0
async def get_client_last_comment(client_id: int) -> dict | None:
"""
Найти последний комментарий по любой незакрытой заявке клиента.
Берём задачу с самым свежим updatedat → ищем последний комментарий в её истории.
Возвращает {text, author, date, task_number} или None.
"""
key = config.INTRADESK_TASKS_API_KEY
if not key:
return None
# Шаг 1: самая свежая задача клиента
url = (
f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks"
f"?ApiKey={key}"
f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)"
f"&$orderby=updatedat desc&$top=1"
)
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=15.0)
if resp.status_code != 200:
return None
data = resp.json()
except Exception:
return None
tasks = data.get("value") or []
if not tasks:
return None
task = tasks[0]
task_id = task.get("id")
task_number = task.get("tasknumber")
if not task_id:
return None
# Шаг 2: последний комментарий этой задачи
comment = await get_task_last_comment(task_id)
if comment:
comment["task_number"] = task_number
return comment
async def get_client_open_tasks(
client_id: int,
page: int = 0,
page_size: int = 5,
) -> dict:
"""
Получить незакрытые заявки клиента из Intradesk API.
Возвращает {"tasks": [...], "total": int}.
Последний комментарий загружается параллельно через history API для каждой задачи.
"""
import asyncio
key = config.INTRADESK_TASKS_API_KEY
if not key:
return {"tasks": [], "total": 0}
skip = page * page_size
url = (
f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks"
f"?ApiKey={key}"
f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)"
f"&$orderby=updatedat desc"
f"&$top={page_size}&$skip={skip}"
f"&$count=true"
)
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=20.0)
if resp.status_code != 200:
logger.warning("get_client_open_tasks client=%s → HTTP %s", client_id, resp.status_code)
return {"tasks": [], "total": 0}
data = resp.json()
except Exception as exc:
logger.warning("get_client_open_tasks error: %s", exc)
return {"tasks": [], "total": 0}
total = data.get("@odata.count") or 0
raw_tasks = data.get("value") or []
# Словари для разрешения имён
dicts_map: dict[int, str] = {}
for d in data.get("dictionaries") or []:
did = d.get("id")
dname = d.get("name") or ""
if isinstance(dname, dict):
dname = dname.get("ru") or dname.get("en") or ""
if did:
dicts_map[did] = dname
# Базовые данные задач
base_tasks = []
for t in raw_tasks:
status_id = t.get("status")
base_tasks.append({
"id": t.get("id"),
"task_number": t.get("tasknumber"),
"name": t.get("name") or "",
"status_name": dicts_map.get(status_id, "") if status_id else "",
"created_at": t.get("createdat") or "",
"updated_at": t.get("updatedat") or "",
})
# Параллельно загружаем последний комментарий для каждой задачи
task_ids = [t["id"] for t in base_tasks if t["id"]]
comments = await asyncio.gather(
*[get_task_last_comment(tid) for tid in task_ids],
return_exceptions=True,
)
tasks = []
for task, comment in zip(base_tasks, comments):
task["last_comment"] = comment if isinstance(comment, dict) else None
tasks.append(task)
return {"tasks": tasks, "total": total}
def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None:
"""Извлечь последний комментарий из поля lifetime задачи (если последнее событие — комментарий)."""
if not lifetime:
return None
for entry in (lifetime.get("data") or []):
for ev in (entry.get("events") or {}).get("data") or []:
if ev.get("blockname") == "comment" and ev.get("type") == 50:
raw_date = entry.get("eventat") or ""
short_date = raw_date[:16].replace("T", " ") # "2026-03-26 11:54"
return {
"text": _strip_html(ev.get("stringvalue") or ""),
"author": entry.get("username") or "",
"date": short_date,
}
return None
async def add_task_comment(
task_number: int,
text: str,
contact_person_id: int | None = None,
) -> bool:
"""
Добавить комментарий к заявке через Intradesk API (PUT /changes/v3/tasks).
Возвращает True при успехе, False при ошибке.
"""
key = config.INTRADESK_CHANGES_API_KEY
if not key:
logger.warning("add_task_comment: INTRADESK_CHANGES_API_KEY не задан")
return False
blocks: dict[str, str] = {
"comment": json.dumps({"value": text}, ensure_ascii=False),
}
body = {"number": task_number, "blocks": blocks, "Channel": "api"}
url = f"{config.INTRADESK_BASE_URL.rstrip('/')}/changes/v3/tasks?ApiKey={key}"
if contact_person_id is not None:
url += f"&userid={contact_person_id}"
if config.DRY_RUN:
print(
"\n[DRY-RUN] add_task_comment — запрос НЕ отправлен\n"
f" PUT {url}\n"
f" Body: {json.dumps(body, ensure_ascii=False, indent=2)}\n"
)
return False
try:
async with httpx.AsyncClient() as client:
resp = await client.put(
url,
json=body,
headers={"Content-Type": "application/json"},
timeout=20.0,
)
if resp.status_code not in (200, 201):
logger.error(
"add_task_comment → HTTP %s: %s",
resp.status_code,
resp.text[:500],
)
return False
logger.info("add_task_comment ok: task_number=%s", task_number)
return True
except Exception as exc:
logger.exception("add_task_comment error: %s", exc)
return False
async def create_intradesk_task(
name: str,
description: str,
client_intradesk_id: int | None = None,
contact_person_id: int | None = None,
attachment_urls: list[str] | None = None,
) -> dict | None:
"""
Создать заявку в Intradesk.
Возвращает ответ API (dict с полями id, tasknumber и др.) или None при ошибке.
Вложения — пока добавляются как ссылки в конец описания (до появления upload API).
В dry-run режиме запрос печатается в консоль и возвращается None.
"""
key = config.INTRADESK_CHANGES_API_KEY
if not key:
logger.warning("create_intradesk_task: INTRADESK_CHANGES_API_KEY не задан")
return None
# Вложения — ссылки в конце описания
full_desc = description
if attachment_urls:
full_desc += "\n\nВложения:\n" + "\n".join(attachment_urls)
blocks: dict[str, str] = {
"name": json.dumps({"value": name}, ensure_ascii=False),
"description": json.dumps({"value": full_desc}, ensure_ascii=False),
}
# initiator: вариант 3 из документации — клиент + пользователь клиента.
# clientid заполняется автоматически из initiatorclient, отдельно не нужен.
if client_intradesk_id is not None and contact_person_id is not None:
blocks["initiator"] = json.dumps(
{"value": {"groupid": client_intradesk_id, "userid": contact_person_id}},
ensure_ascii=False,
)
elif client_intradesk_id is not None:
# только клиент (нет конкретного контактного лица)
blocks["initiator"] = json.dumps(
{"value": {"groupid": client_intradesk_id}}, ensure_ascii=False
)
if config.INTRADESK_DEFAULT_SERVICE:
blocks["service"] = json.dumps(
{"value": config.INTRADESK_DEFAULT_SERVICE}, ensure_ascii=False
)
if config.INTRADESK_DEFAULT_TASK_TYPE:
blocks["tasktype"] = json.dumps(
{"value": int(config.INTRADESK_DEFAULT_TASK_TYPE)}, ensure_ascii=False
)
body = {"blocks": blocks, "Channel": "web"}
url = f"{config.INTRADESK_BASE_URL.rstrip('/')}/changes/v3/tasks?ApiKey={key}"
if contact_person_id is not None:
url += f"&userid={contact_person_id}"
if config.DRY_RUN:
print(
"\n[DRY-RUN] create_intradesk_task — запрос НЕ отправлен\n"
f" POST {url}\n"
f" Body: {json.dumps(body, ensure_ascii=False, indent=2)}\n"
)
return None
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
url,
json=body,
headers={"Content-Type": "application/json"},
timeout=20.0,
)
if resp.status_code not in (200, 201):
logger.error(
"create_intradesk_task → HTTP %s: %s",
resp.status_code,
resp.text[:500],
)
return None
result = resp.json()
# API может вернуть как "Number"/"Id" (PascalCase), так и "number"/"id"
task_id = result.get("id") or result.get("Id") or result.get("ID")
task_number = (
result.get("tasknumber")
or result.get("number")
or result.get("Number")
or result.get("TaskNumber")
)
logger.info("create_intradesk_task ok: id=%s number=%s", task_id, task_number)
return result
except Exception as exc:
logger.exception("create_intradesk_task error: %s", exc)
return None