From 6f0e66b4aee1f02ab78cbb5dab13444eda2c6b0e Mon Sep 17 00:00:00 2001 From: dv Date: Thu, 26 Mar 2026 16:16:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=BA=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/handlers/create_ticket.py | 3 +- bot/handlers/menu.py | 70 ++++++++++---- bot/handlers/start.py | 8 +- bot/handlers/view_tickets.py | 143 ++++++++++++++++++++++++++-- bot/keyboards.py | 17 ++-- bot/states.py | 4 + data/support.db-shm | Bin 32768 -> 32768 bytes data/support.db-wal | Bin 5800992 -> 5800992 bytes external/intradesk_api.py | 170 ++++++++++++++++++++++++++++++++-- 9 files changed, 369 insertions(+), 46 deletions(-) diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index b966c58..ef9422a 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -44,12 +44,13 @@ async def cleanup_ticket_flow(bot, context: BaseContext) -> None: Вызывается из любого обработчика, который прерывает этот поток (главное меню, список заявок, повторное нажатие «Создать заявку»). """ - from bot.states import TicketStates # avoid circular at module level + from bot.states import TicketStates, CommentStates # avoid circular at module level state = await context.get_state() creation_states = [ TicketStates.waiting_description, TicketStates.collecting_attachments, TicketStates.confirming, + CommentStates.waiting_text, ] if state not in creation_states: return diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 132758e..f49f013 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -1,47 +1,79 @@ """Обработчик главного меню.""" +import asyncio + +from maxapi.context import BaseContext from maxapi.dispatcher import Router from maxapi.filters import F from maxapi.types.updates.message_callback import MessageCallback -from maxapi.context import BaseContext from bot.keyboards import main_menu_kb from bot.send_helper import bot_reply -from database.connection import get_db -from database.queries.intradesk import ( - count_active_tasks_for_local_org, - count_tasks_for_local_org, -) from database.queries.organizations import get_organization +from external.intradesk_api import count_client_open_tasks, get_client_last_comment router = Router(router_id="menu") async def build_main_menu_text(bot_user: dict) -> str: - """Формирует текст главного меню с именем объекта и статистикой заявок.""" + """Формирует текст главного меню — данные берутся из Intradesk API.""" org_id = bot_user.get("org_id") org_name: str | None = None + intradesk_id: int | None = None if org_id: org = await get_organization(org_id) org_name = org["name"] if org else None + intradesk_id = org.get("intradesk_id") if org else None lines: list[str] = [] - if org_name: - lines.append(f"🏢 {org_name}") - else: - lines.append("🏠 Главное меню") + lines.append(f"🏢 {org_name}" if org_name else "🏠 Поддержка") + lines.append("━" * 16) - if org_id: - db = get_db() - total = await count_tasks_for_local_org(db, org_id) - active = await count_active_tasks_for_local_org(db, org_id) + if intradesk_id: + # Счётчик активных и последний комментарий — параллельно + active, last_comment = await asyncio.gather( + count_client_open_tasks(intradesk_id), + get_client_last_comment(intradesk_id), + ) - if total == 0: - lines.append("Заявок пока нет.") + if active == 0: + lines += [ + "📭 Активных заявок нет", + "", + "Нажмите «Создать заявку» и мы", + "свяжемся с вами в ближайшее время.", + ] else: - lines.append(f"Заявок: {total} (активных: {active})") + lines.append(f"📋 Активных заявок: {active}") - lines.append("\nВыберите действие:") + if last_comment and last_comment.get("text"): + ctext = last_comment["text"] + if len(ctext) > 120: + ctext = ctext[:120] + "…" + raw_date = last_comment.get("date") or "" + # raw_date приходит как "2026-03-26 11:54" + date_part = raw_date[:10] + time_part = raw_date[11:16] if len(raw_date) >= 16 else "" + if len(date_part) == 10: + y, m, d = date_part.split("-") + short_date = f"{d}.{m}" + (f" {time_part}" if time_part else "") + else: + short_date = raw_date + author = (last_comment.get("author") or "").strip() + task_num = last_comment.get("task_number") + num_str = f" (заявка #{task_num})" if task_num else "" + lines += [ + "", + f"💬 Последний ответ{num_str}:", + f"«{ctext}»", + f"— {author}, {short_date}", + ] + else: + lines += ["", "💬 Новых ответов нет"] + else: + lines.append("📭 Нет данных об организации") + + lines += ["", "━" * 16, "Выберите действие 👇"] return "\n".join(lines) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 91aa192..1b001dd 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -68,11 +68,13 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None: if bot_user["is_authorized"]: logger.info("✅ bot_started returning user | user_id=%s", user.user_id) await context.set_state(None) + from bot.handlers.menu import build_main_menu_text + menu_text = "👋 С возвращением!\n\n" + await build_main_menu_text(bot_user) await bot_send( event.bot, chat_id=event.chat_id, user_id=user.user_id, - text="👋 С возвращением! Выберите действие:", + text=menu_text, attachments=[main_menu_kb()], ) return @@ -141,11 +143,13 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None: return await context.set_state(None) + from bot.handlers.menu import build_main_menu_text + menu_text = await build_main_menu_text(bot_user) await bot_send( event.bot, chat_id=chat_id, user_id=user_id, - text="Главное меню:", + text=menu_text, attachments=[main_menu_kb()], ) diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index efbb693..10041bf 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -19,11 +19,13 @@ from maxapi.filters import F from maxapi.types.updates.message_callback import MessageCallback from bot.filters import PayloadStartsWith -from bot.keyboards import iticket_detail_kb, menu_only_kb -from bot.send_helper import bot_reply +from bot.keyboards import iticket_detail_kb, main_menu_kb, menu_only_kb +from bot.send_helper import bot_reply, bot_send +from bot.states import CommentStates from database.connection import get_db +from database.queries.intradesk import get_intradesk_contact_id_for_phone from database.queries.organizations import get_organization -from external.intradesk_api import get_client_open_tasks, get_task_last_comment +from external.intradesk_api import add_task_comment, get_client_open_tasks, get_task_last_comment router = Router(router_id="view_tickets") @@ -42,19 +44,22 @@ def _status_icon(status_name: str) -> str: def _fmt_date(iso: str) -> str: - """'2026-03-26T09:57:11Z' → '26.03.2026'""" + """'2026-03-26T09:57:11Z' → '26.03.2026 09:57'""" if not iso or len(iso) < 10: return iso or "—" y, m, d = iso[:10].split("-") - return f"{d}.{m}.{y}" + # iso может быть "2026-03-26T09:57:11Z" или "2026-03-26 09:57" + time_part = iso[11:16] if len(iso) >= 16 else "" + return f"{d}.{m}.{y}" + (f" {time_part}" if time_part else "") def _fmt_date_short(iso: str) -> str: - """'2026-03-26' → '26.03'""" + """'2026-03-26 11:54' → '26.03 11:54'""" if not iso or len(iso) < 10: return iso or "" y, m, d = iso[:10].split("-") - return f"{d}.{m}" + time_part = iso[11:16] if len(iso) >= 16 else "" + return f"{d}.{m}" + (f" {time_part}" if time_part else "") def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str: @@ -79,9 +84,11 @@ def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str: comment = t.get("last_comment") if comment and comment.get("text"): ctext = comment["text"][:80] + ("…" if len(comment["text"]) > 80 else "") - author_short = (comment.get("author") or "").split()[0] # только фамилия + author_short = (comment.get("author") or "—").split()[0] cdate = _fmt_date_short(comment.get("date") or "") lines.append(f'💬 "{ctext}" — {author_short} ({cdate})') + else: + lines.append("💬 Нет комментариев") return "\n".join(lines) @@ -247,7 +254,125 @@ async def cb_iticket_detail( await bot_reply( event, text="\n".join(lines), - attachments=[iticket_detail_kb()], + attachments=[iticket_detail_kb(task_id=task["id"])], user_id=bot_user["max_user_id"], chat_id=event.message.recipient.chat_id if event.message else None, ) + + +# ── Оставить комментарий ────────────────────────────────────────────────────── + +@router.message_callback(PayloadStartsWith("iticket_comment:")) +async def cb_add_comment( + event: MessageCallback, context: BaseContext, bot_user: dict +) -> None: + try: + task_id = int(event.callback.payload.split(":")[1]) + except (IndexError, ValueError): + await event.answer(notification="Ошибка") + return + + await event.answer(notification="") + + # Ищем task_number в кэше контекста + data = await context.get_data() + task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None) + task_number = task.get("task_number") if task else None + + await context.set_state(CommentStates.waiting_text) + await context.set_data({ + "comment_task_id": task_id, + "comment_task_number": task_number, + "flow_message_ids": [], + }) + + chat_id = event.message.recipient.chat_id if event.message else None + if chat_id is None: + return + + num_str = f" к заявке #{task_number}" if task_number else "" + mid = await bot_send( + event.bot, + chat_id=chat_id, + user_id=bot_user["max_user_id"], + text=( + f"💬 Напишите комментарий{num_str}:\n\n" + "Для отмены нажмите «🏠 Главное меню»." + ), + attachments=[menu_only_kb()], + ) + # Сохраняем mid для возможной очистки + if mid: + d = await context.get_data() + d["flow_message_ids"] = [mid] + await context.set_data(d) + + +@router.message_created(states=CommentStates.waiting_text) +async def on_comment_text( + event: MessageCreated, context: BaseContext, bot_user: dict +) -> None: + chat_id, user_id = event.get_ids() + if chat_id is None: + return + + body = event.message.body + text = (body.text or "").strip() if body else "" + if not text: + await bot_send( + event.bot, + chat_id=chat_id, + user_id=user_id, + text="❗ Комментарий не может быть пустым. Напишите текст:", + attachments=[menu_only_kb()], + ) + return + + data = await context.get_data() + task_number = data.get("comment_task_number") + + # Сбрасываем состояние до отправки API — защита от повторов + await context.clear() + + # Получаем contact_person_id + contact_person_id: int | None = None + if bot_user.get("phone") and bot_user.get("org_id"): + org = await get_organization(bot_user["org_id"]) + intradesk_id = org.get("intradesk_id") if org else None + if intradesk_id: + db = get_db() + contact_person_id = await get_intradesk_contact_id_for_phone( + db, intradesk_id, bot_user["phone"] + ) + + ok = await add_task_comment( + task_number=task_number, + text=text, + contact_person_id=contact_person_id, + ) + + from bot.handlers.menu import build_main_menu_text + + if ok: + num_str = f" к заявке #{task_number}" if task_number else "" + await bot_send( + event.bot, + chat_id=chat_id, + user_id=user_id, + text=f"✅ Комментарий{num_str} успешно отправлен!", + ) + else: + await bot_send( + event.bot, + chat_id=chat_id, + user_id=user_id, + text="⚠️ Не удалось отправить комментарий. Попробуйте позже.", + ) + + await bot_send( + event.bot, + chat_id=chat_id, + user_id=user_id, + text=await build_main_menu_text(bot_user), + attachments=[main_menu_kb()], + ) diff --git a/bot/keyboards.py b/bot/keyboards.py index f726010..d5f86bd 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -118,14 +118,17 @@ def intradesk_tasks_list_kb( return builder.as_markup() -def iticket_detail_kb() -> Attachment: +def iticket_detail_kb(task_id: int | None = None) -> Attachment: """Клавиатура внутри карточки Intradesk-задачи.""" - return ( - InlineKeyboardBuilder() - .row(CallbackButton(text="← К списку заявок", payload="itickets_page:0")) - .row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) - .as_markup() - ) + builder = InlineKeyboardBuilder() + if task_id is not None: + builder.row(CallbackButton( + text="💬 Оставить комментарий", + payload=f"iticket_comment:{task_id}", + )) + builder.row(CallbackButton(text="← К списку заявок", payload="itickets_page:0")) + builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) + return builder.as_markup() def ticket_detail_kb(page: int = 0) -> Attachment: diff --git a/bot/states.py b/bot/states.py index a71f9fe..2bcb56c 100644 --- a/bot/states.py +++ b/bot/states.py @@ -9,3 +9,7 @@ class TicketStates(StatesGroup): waiting_description = State() # Ожидаем первое сообщение с описанием collecting_attachments = State() # Накапливаем доп. файлы/текст confirming = State() # Предпросмотр — ждём подтверждения + + +class CommentStates(StatesGroup): + waiting_text = State() # Ожидаем текст комментария к заявке diff --git a/data/support.db-shm b/data/support.db-shm index f99e54882b0bea063c36196a4c311439d7512f30..f39aba22b2ef56597ab5d542637d5e2f7b90d53f 100644 GIT binary patch delta 773 zcmchVH%Np*5QV?p^UgyMYz(cPji92HU?CQEf{k_xf~{Ct-dS3otvTm#XU=ENXU;k2 zoa6p_g5pb^3IaTg$`we6C<;ugua2{|ggTP#LD30A zQDyBRNzvAy_J0gl&Y8Rl7Hg*7(VVuw8rIO2peF1g{32cCH04Iy+wFIR&vC~5z0}L_37!D?x wW>&SWNbg0KS!JC~w%KK$LykG+Tow1E*EP4?^T;!=>W@p|A`HT)$qDv)2U@SPHUIzs delta 188 zcmZo@U}|V!s+V}A%K!pwK+MR%ATS3=FJfX~kbM-MurrTOeYf?6!b3(u$!d+u?laF& zk|R|;$ZQap1;qc609cTLfr(+l#>R4{&6`=g?KU4^+08ilpA*~WO)TG7C;#%80%Vx* UZ9Wuk01`UIGg07yt40Q0{-W&i*H diff --git a/data/support.db-wal b/data/support.db-wal index eddd421ab6bda99dcf0bd8e42cb0b703e2ca529a..adba682362bc96ba59b3b6bb0ec24928825dd7a2 100644 GIT binary patch delta 48498 zcmeHQ34Bvk)^GOYC2d-0Sz4qi6rq)tyd}+I1zeHEB?7}Z4${)5SP+#FM3lv(MOIk^ zdGL)wSrixq`>>Es*OJs33?SFns5}m!+>wnxeooc=>VqYhTkg@1Ar1 z|8veg_q?*nHS#i$7fNNN@=`^qvQ$;7F4dH3D|9>dBq?Ix--cWH5!yHr2W_4 zuh{iA)qbO_hTUBw>&Q;Kq>XK-|E$f)hdU*oj)hn2bk>--3vq*D&l}bm82x;rpYB7Q zm2=*tQ@UHZ2eaH{br<%Qsk%ZfV|I`^gAztpiI4-b52ql+@stsaiKkfd<$J<&SxmfMY0E<&h;X_Q|ABS zect->&#!&VvIoTbJQBRm%+mS))>pO}KMvhztZHr8`&6yvoU>F0_Xu~9?@kB!@3hcn zwGPjR z%I%zUq0-=f+&#p%D)))2VkembmzA=a+2N;h*9a?Ozk$}S;)m(q7p#2I=lI8hYnPQU zb?XmrEL&KN*3PJSzrnRrcoSORyh4GD>M^NN*=dG$nO$U|#HeWNA8w)F9^!iNc(&nd z(MXR67rM*B)c4zECXeca7CK%*HoVa9E7-JcCKH$UtsGHOu|P=#WvA_y!WeTY}jAy1N{9|f0(rI&l?DlK!t(St8ilH5&y~hwLe>SMxLUIi;0mhWw)M@caz1f|5;vc)K)z5 zVUM=e9d?Ko@jd(3o@%DkyxSJ99`IxH6Ds&cgmEW)lrJ@2FrGD@GM+FVH6AqXH-2o~ zYuxEQnW$K4G{Co)xF3g)i`@^i!yi^>r!GZ5;m`iQ*t#hreV1|}GA&tLub8-AoU?nJ zp@eb|7lB~n!$Pz-!4Kce$wf1lm`o#GPmiRDnz`<)@}D@lXwGUAKZ~!b_VAVbT7FaY zwCaf_-piL)&x9{G^SUY%zlmR0?IBp%+quoj#c<8bSHSl@)zgD7tg0^AzQM#-n)r>? z(}-#}zZQOeJ-?3M$Z|84R4?MHy=9hcrD$h1om@0~5@n`xvdnZAm180)2e=h&h1HZK z+25{y)B57Qxp|@{e-L~-O`-^X{ND3N`z0Pjijc=10!73g;+*?pfk2-S3ACt)9Gp+v z3k3pAV5|;{9jhsl0bZG!a%q+Ir-7^2ifZ~%P*XdJnkIhL;nca0lH-w@6b4B8K}`mf z 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 E57gn~pa1{> delta 15183 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 diff --git a/external/intradesk_api.py b/external/intradesk_api.py index 06b41d9..9ec2a61 100644 --- a/external/intradesk_api.py +++ b/external/intradesk_api.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import logging +import re import httpx @@ -16,6 +17,18 @@ 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: """ @@ -53,13 +66,88 @@ async def get_task_last_comment(task_id: int) -> dict | None: # "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54" short_date = raw_date[:16].replace("T", " ") return { - "text": ev.get("stringvalue") or "", + "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, @@ -69,8 +157,10 @@ async def get_client_open_tasks( Получить незакрытые заявки клиента из Intradesk API. Возвращает {"tasks": [...], "total": int}. - Каждая задача содержит поля из OData + "last_comment" (dict или None). + Последний комментарий загружается параллельно через history API для каждой задачи. """ + import asyncio + key = config.INTRADESK_TASKS_API_KEY if not key: return {"tasks": [], "total": 0} @@ -108,20 +198,31 @@ async def get_client_open_tasks( if did: dicts_map[did] = dname - tasks = [] + # Базовые данные задач + base_tasks = [] for t in raw_tasks: status_id = t.get("status") - last_comment = _extract_last_comment_from_lifetime(t.get("lifetime")) - tasks.append({ + 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 "", - "last_comment": last_comment, }) + # Параллельно загружаем последний комментарий для каждой задачи + 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} @@ -133,15 +234,68 @@ def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None: 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[:10] # "2026-03-26" + short_date = raw_date[:16].replace("T", " ") # "2026-03-26 11:54" return { - "text": ev.get("stringvalue") or "", + "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,