This commit is contained in:
dv 2026-03-26 15:39:02 +03:00
parent 547a4a1dcb
commit 7fab711886
8 changed files with 322 additions and 84 deletions

View file

@ -6,7 +6,9 @@
"Bash(python -c \"import maxapi; import inspect; members = [m for m in dir\\(maxapi\\) if not m.startswith\\(''_''\\)]; print\\(members\\)\")", "Bash(python -c \"import maxapi; import inspect; members = [m for m in dir\\(maxapi\\) if not m.startswith\\(''_''\\)]; print\\(members\\)\")",
"Bash(python -c \"from maxapi import Bot; methods = [m for m in dir\\(Bot\\) if not m.startswith\\(''_''\\)]; print\\(methods\\)\")", "Bash(python -c \"from maxapi import Bot; methods = [m for m in dir\\(Bot\\) if not m.startswith\\(''_''\\)]; print\\(methods\\)\")",
"Bash(python -c \"from maxapi import Bot; import inspect; print\\(inspect.signature\\(Bot.edit_message\\)\\)\")", "Bash(python -c \"from maxapi import Bot; import inspect; print\\(inspect.signature\\(Bot.edit_message\\)\\)\")",
"Bash(python -c \"from maxapi.types.updates.message_callback import MessageCallback; print\\([m for m in dir\\(MessageCallback\\) if not m.startswith\\(''_''\\)]\\)\")" "Bash(python -c \"from maxapi.types.updates.message_callback import MessageCallback; print\\([m for m in dir\\(MessageCallback\\) if not m.startswith\\(''_''\\)]\\)\")",
"Bash(python -c \"from maxapi.types import SendedMessage; print\\(SendedMessage.__fields__ if hasattr\\(SendedMessage, ''__fields__''\\) else dir\\(SendedMessage\\)\\)\")",
"Bash(python -c \"from maxapi.context import State; s = State\\(\\); print\\(type\\(s\\), dir\\(s\\)\\)\")"
] ]
} }
} }

View file

@ -37,6 +37,54 @@ logger = get_logger("bot.handlers.create_ticket")
router = Router(router_id="create_ticket") router = Router(router_id="create_ticket")
async def cleanup_ticket_flow(bot, context: BaseContext) -> None:
"""
Удалить висящие сообщения незавершённого потока создания заявки.
Вызывается из любого обработчика, который прерывает этот поток
(главное меню, список заявок, повторное нажатие «Создать заявку»).
"""
from bot.states import TicketStates # avoid circular at module level
state = await context.get_state()
creation_states = [
TicketStates.waiting_description,
TicketStates.collecting_attachments,
TicketStates.confirming,
]
if state not in creation_states:
return
data = await context.get_data()
for mid in (data.get("flow_message_ids") or []):
try:
await bot.delete_message(message_id=mid)
except Exception:
pass
await context.clear()
async def _track_mid(context: BaseContext, mid: str | None) -> None:
"""Добавить message_id в список сообщений потока создания."""
if not mid:
return
data = await context.get_data()
ids: list[str] = data.get("flow_message_ids") or []
ids.append(mid)
data["flow_message_ids"] = ids
await context.set_data(data)
async def _delete_flow_messages(bot, context: BaseContext, extra_mid: str | None = None) -> None:
"""Удалить все сообщения, накопленные за время потока создания."""
data = await context.get_data()
ids: list[str] = list(data.get("flow_message_ids") or [])
if extra_mid:
ids.append(extra_mid)
for mid in ids:
try:
await bot.delete_message(message_id=mid)
except Exception:
pass # сообщение уже удалено или слишком старое
_MEDIA_TYPES = { _MEDIA_TYPES = {
AttachmentType.IMAGE, AttachmentType.IMAGE,
AttachmentType.VIDEO, AttachmentType.VIDEO,
@ -120,8 +168,11 @@ def _preview_text(org_name: str | None, description: str, pending_atts: list[dic
async def cb_create_ticket( async def cb_create_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict event: MessageCallback, context: BaseContext, bot_user: dict
) -> None: ) -> None:
# Зачищаем предыдущий незавершённый поток, если был
await cleanup_ticket_flow(event.bot, context)
await context.set_state(TicketStates.waiting_description) await context.set_state(TicketStates.waiting_description)
await context.set_data({"pending_attachments": []}) await context.set_data({"pending_attachments": [], "flow_message_ids": []})
logger.info("📝 create_ticket started | user_id=%s", bot_user["max_user_id"]) logger.info("📝 create_ticket started | user_id=%s", bot_user["max_user_id"])
await event.answer(notification="") await event.answer(notification="")
@ -129,7 +180,7 @@ async def cb_create_ticket(
if chat_id is None: if chat_id is None:
return return
await bot_send( mid = await bot_send(
event.bot, event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"], user_id=bot_user["max_user_id"],
@ -140,6 +191,7 @@ async def cb_create_ticket(
), ),
attachments=[done_ticket_kb()], attachments=[done_ticket_kb()],
) )
await _track_mid(context, mid)
# ── Шаг 2: первое сообщение с описанием ────────────────────────────────────── # ── Шаг 2: первое сообщение с описанием ──────────────────────────────────────
@ -159,7 +211,7 @@ async def on_ticket_description(
await context.update_data(description=text, pending_attachments=attachments) await context.update_data(description=text, pending_attachments=attachments)
await context.set_state(TicketStates.collecting_attachments) await context.set_state(TicketStates.collecting_attachments)
await bot_send( mid = await bot_send(
event.bot, event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id, user_id=user_id,
@ -169,6 +221,7 @@ async def on_ticket_description(
), ),
attachments=[done_ticket_kb()], attachments=[done_ticket_kb()],
) )
await _track_mid(context, mid)
# ── Шаг 3: дополнительные вложения/текст ───────────────────────────────────── # ── Шаг 3: дополнительные вложения/текст ─────────────────────────────────────
@ -227,13 +280,14 @@ async def cb_done_ticket(
await context.set_state(TicketStates.confirming) await context.set_state(TicketStates.confirming)
await bot_send( mid = await bot_send(
event.bot, event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"], user_id=bot_user["max_user_id"],
text=_preview_text(org_name, description, pending_atts), text=_preview_text(org_name, description, pending_atts),
attachments=[confirm_ticket_kb()], attachments=[confirm_ticket_kb()],
) )
await _track_mid(context, mid)
# ── Шаг 5б: "Продолжить редактирование" ────────────────────────────────────── # ── Шаг 5б: "Продолжить редактирование" ──────────────────────────────────────
@ -252,7 +306,7 @@ async def cb_edit_ticket(
if chat_id is None: if chat_id is None:
return return
await bot_send( mid = await bot_send(
event.bot, event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"], user_id=bot_user["max_user_id"],
@ -262,6 +316,7 @@ async def cb_edit_ticket(
), ),
attachments=[done_ticket_kb()], attachments=[done_ticket_kb()],
) )
await _track_mid(context, mid)
# ── Шаг 5в: "Отменить создание" ────────────────────────────────────────────── # ── Шаг 5в: "Отменить создание" ──────────────────────────────────────────────
@ -274,6 +329,10 @@ async def cb_cancel_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict event: MessageCallback, context: BaseContext, bot_user: dict
) -> None: ) -> None:
await event.answer(notification="Создание отменено") await event.answer(notification="Создание отменено")
# Удаляем все сообщения потока + текущее сообщение с кнопками
callback_mid = event.message.body.mid if (event.message and event.message.body) else None
await _delete_flow_messages(event.bot, context, extra_mid=callback_mid)
await context.clear() await context.clear()
chat_id = event.message.recipient.chat_id if event.message else None chat_id = event.message.recipient.chat_id if event.message else None
@ -301,6 +360,9 @@ async def cb_confirm_ticket(
# Защита от двойного нажатия: читаем данные и сбрасываем состояние атомарно. # Защита от двойного нажатия: читаем данные и сбрасываем состояние атомарно.
# Повторный tap уже не пройдёт фильтр states=confirming. # Повторный tap уже не пройдёт фильтр states=confirming.
data = await context.get_data() data = await context.get_data()
# Удаляем все сообщения потока + текущее (предпросмотр с кнопками)
callback_mid = event.message.body.mid if (event.message and event.message.body) else None
await _delete_flow_messages(event.bot, context, extra_mid=callback_mid)
await context.clear() await context.clear()
description = (data.get("description") or "").strip() description = (data.get("description") or "").strip()

View file

@ -49,6 +49,8 @@ async def build_main_menu_text(bot_user: dict) -> str:
async def cb_main_menu( async def cb_main_menu(
event: MessageCallback, context: BaseContext, bot_user: dict event: MessageCallback, context: BaseContext, bot_user: dict
) -> None: ) -> None:
from bot.handlers.create_ticket import cleanup_ticket_flow
await cleanup_ticket_flow(event.bot, context)
await context.set_state(None) await context.set_state(None)
await event.answer(notification="") await event.answer(notification="")

View file

@ -1,13 +1,17 @@
""" """
Обработчики просмотра заявок (Intradesk). Обработчики просмотра заявок (Intradesk API).
Навигация через callback payload все переходы редактируют текущее сообщение: Заявки загружаются из Intradesk API по нажатию кнопки только незакрытые.
Навигация через callback payload:
"view_tickets" список, страница 0 "view_tickets" список, страница 0
"itickets_page:{n}" список, страница n "itickets_page:{n}" список, страница n
"iticket_detail:{id}" карточка задачи "iticket_detail:{id}" карточка задачи (из кэша в context)
"main_menu" в menu.py "main_menu" в menu.py
""" """
from __future__ import annotations
import re import re
import time
from maxapi.context import BaseContext from maxapi.context import BaseContext
from maxapi.dispatcher import Router from maxapi.dispatcher import Router
@ -15,24 +19,20 @@ from maxapi.filters import F
from maxapi.types.updates.message_callback import MessageCallback from maxapi.types.updates.message_callback import MessageCallback
from bot.filters import PayloadStartsWith from bot.filters import PayloadStartsWith
from bot.keyboards import intradesk_tasks_list_kb, iticket_detail_kb, menu_only_kb from bot.keyboards import iticket_detail_kb, menu_only_kb
from bot.send_helper import bot_reply from bot.send_helper import bot_reply
from database.connection import get_db from database.connection import get_db
from database.queries.intradesk import ( from database.queries.organizations import get_organization
count_tasks_for_local_org, from external.intradesk_api import get_client_open_tasks, get_task_last_comment
get_task_for_local_org,
get_tasks_for_local_org,
)
from external.intradesk_api import get_task_last_comment
router = Router(router_id="view_tickets") router = Router(router_id="view_tickets")
_PAGE_SIZE = 8 _PAGE_SIZE = 5
def _status_icon(status_name: str) -> str: def _status_icon(status_name: str) -> str:
sn = status_name.lower() sn = status_name.lower()
if "выполнен" in sn: if "выполнен" in sn or "закрыт" in sn:
return "🟢" return "🟢"
if "работ" in sn: if "работ" in sn:
return "🟡" return "🟡"
@ -41,43 +41,144 @@ def _status_icon(status_name: str) -> str:
return "" return ""
def _priority_icon(priority_name: str) -> str: def _fmt_date(iso: str) -> str:
pn = priority_name.lower() """'2026-03-26T09:57:11Z''26.03.2026'"""
if "критич" in pn: if not iso or len(iso) < 10:
return "🔴" return iso or ""
if "высок" in pn: y, m, d = iso[:10].split("-")
return "🟠" return f"{d}.{m}.{y}"
if "средн" in pn:
return "🟡"
return ""
async def _tasks_page_text(org_id: int, page: int) -> tuple[str, list[dict], int]: def _fmt_date_short(iso: str) -> str:
"""Возвращает (текст, задачи, всего).""" """'2026-03-26''26.03'"""
db = get_db() if not iso or len(iso) < 10:
tasks = await get_tasks_for_local_org(db, org_id, page=page, page_size=_PAGE_SIZE) return iso or ""
total = await count_tasks_for_local_org(db, org_id) y, m, d = iso[:10].split("-")
if not tasks: return f"{d}.{m}"
return "📭 У вашей организации пока нет заявок.", [], 0
def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
pages = max(1, -(-total // _PAGE_SIZE)) pages = max(1, -(-total // _PAGE_SIZE))
text = f"📋 Заявки — стр. {page + 1} из {pages} (всего: {total}):" lines = [f"📋 Активные заявки (всего: {total}) — стр. {page + 1}/{pages}"]
return text, tasks, total
for t in tasks:
icon = _status_icon(t.get("status_name") or "")
num = t.get("task_number") or t.get("id") or "?"
name = (t.get("name") or "")[:60]
created = _fmt_date(t.get("created_at") or "")
status = t.get("status_name") or ""
lines += [
"",
"" * 20,
f"📌 №{num} · {created}",
f"{name}",
f"{icon} {status}",
]
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] # только фамилия
cdate = _fmt_date_short(comment.get("date") or "")
lines.append(f'💬 "{ctext}"{author_short} ({cdate})')
return "\n".join(lines)
async def _load_and_show(
event: MessageCallback,
bot_user: dict,
page: int,
context: BaseContext,
) -> None:
"""
Загрузить страницу заявок из API и показать.
Сразу редактирует сообщение на « Загрузка...», затем делает запрос к API.
Перед рендером проверяет load_token если пользователь успел уйти
в другой флоу, контекст изменится и мы тихо выходим.
"""
org_id = bot_user.get("org_id")
intradesk_client_id: int | None = None
if org_id:
org = await get_organization(org_id)
intradesk_client_id = org.get("intradesk_id") if org else None
# Уникальный токен для этого запроса
token = str(time.monotonic_ns())
mid = event.message.body.mid if (event.message and event.message.body) else None
chat_id = event.message.recipient.chat_id if event.message else None
# Записываем токен в контекст и сразу показываем загрузку
await context.set_data({"load_token": token})
await bot_reply(
event,
text="⏳ Загружаем список заявок...",
attachments=None,
user_id=bot_user["max_user_id"],
chat_id=chat_id,
)
if not intradesk_client_id:
# Проверяем токен перед рендером
data = await context.get_data()
if data.get("load_token") != token:
return
await _edit_or_send(event.bot, mid, chat_id,
text="⚠️ Ваша организация не привязана к Intradesk. Обратитесь к администратору.",
attachments=[menu_only_kb()])
return
# Запрос к API
result = await get_client_open_tasks(intradesk_client_id, page=page, page_size=_PAGE_SIZE)
tasks = result["tasks"]
total = result["total"]
# Проверяем токен — не ушёл ли пользователь пока грузили
data = await context.get_data()
if data.get("load_token") != token:
return # Пользователь уже в другом флоу — не трогаем его экран
# Сохраняем задачи в контексте для детального просмотра
await context.set_data({"tasks": tasks, "page": page, "total": total})
if not tasks:
await _edit_or_send(event.bot, mid, chat_id,
text="📭 Активных заявок нет.",
attachments=[menu_only_kb()])
return
from bot.keyboards import intradesk_tasks_list_kb
text = _build_tasks_text(tasks, page, total)
kb = intradesk_tasks_list_kb(tasks, page=page, total=total, page_size=_PAGE_SIZE)
await _edit_or_send(event.bot, mid, chat_id, text=text, attachments=[kb])
async def _edit_or_send(bot, mid: str | None, chat_id: int | None, *, text: str, attachments) -> None:
"""Редактировать сообщение по mid или отправить новое если mid недоступен."""
if mid:
try:
await bot.edit_message(
message_id=mid,
text=text,
attachments=attachments if attachments is not None else [],
)
return
except Exception:
pass
if chat_id:
await bot.send_message(chat_id=chat_id, text=text, attachments=attachments or [])
@router.message_callback(F.callback.payload == "view_tickets") @router.message_callback(F.callback.payload == "view_tickets")
async def cb_view_tickets( async def cb_view_tickets(
event: MessageCallback, context: BaseContext, bot_user: dict event: MessageCallback, context: BaseContext, bot_user: dict
) -> None: ) -> None:
from bot.handlers.create_ticket import cleanup_ticket_flow
await cleanup_ticket_flow(event.bot, context)
await event.answer(notification="") await event.answer(notification="")
text, tasks, total = await _tasks_page_text(bot_user["org_id"], 0) await _load_and_show(event, bot_user, page=0, context=context)
kb = intradesk_tasks_list_kb(tasks, page=0, total=total) if tasks else menu_only_kb()
await bot_reply(
event,
text=text,
attachments=[kb],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)
@router.message_callback(PayloadStartsWith("itickets_page:")) @router.message_callback(PayloadStartsWith("itickets_page:"))
@ -89,15 +190,7 @@ async def cb_itickets_page(
page = int(event.callback.payload.split(":")[1]) page = int(event.callback.payload.split(":")[1])
except (IndexError, ValueError): except (IndexError, ValueError):
page = 0 page = 0
text, tasks, total = await _tasks_page_text(bot_user["org_id"], page) await _load_and_show(event, bot_user, page=page, context=context)
kb = intradesk_tasks_list_kb(tasks, page=page, total=total) if tasks else menu_only_kb()
await bot_reply(
event,
text=text,
attachments=[kb],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)
@router.message_callback(PayloadStartsWith("iticket_detail:")) @router.message_callback(PayloadStartsWith("iticket_detail:"))
@ -110,42 +203,35 @@ async def cb_iticket_detail(
await event.answer(notification="Ошибка") await event.answer(notification="Ошибка")
return return
db = get_db()
task = await get_task_for_local_org(db, task_id, bot_user["org_id"])
if task is None:
await event.answer(notification="Нет доступа")
return
await event.answer(notification="") await event.answer(notification="")
sicon = _status_icon(task.get("status_name") or "") # Ищем задачу в кэше контекста (загружена при показе списка)
picon = _priority_icon(task.get("priority_name") or "") data = await context.get_data()
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
if task is None:
await bot_reply(
event,
text="⚠️ Заявка не найдена. Вернитесь к списку.",
attachments=[iticket_detail_kb()],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)
return
icon = _status_icon(task.get("status_name") or "")
num = task.get("task_number") or task["id"] num = task.get("task_number") or task["id"]
lines = [ lines = [
f"📌 Заявка #{num}", f"📌 Заявка #{num}",
f"Статус: {sicon} {task.get('status_name') or ''}", f"Статус: {icon} {task.get('status_name') or ''}",
f"Приоритет: {picon} {task.get('priority_name') or ''}", f"Создана: {_fmt_date(task.get('created_at') or '')}",
f"Обновлена: {_fmt_date(task.get('updated_at') or '')}",
] ]
if task.get("service_name"):
lines.append(f"Сервис: {task['service_name']}")
if task.get("executor_name"):
lines.append(f"Исполнитель: {task['executor_name']}")
lines.append(f"Создана: {(task.get('created_at') or '')[:10] or ''}")
lines.append(f"Обновлена: {(task.get('updated_at') or '')[:10] or ''}")
if task.get("name"): if task.get("name"):
lines += ["", f"📄 {task['name']}"] lines += ["", f"📄 {task['name']}"]
if task.get("description"): # Последний комментарий — отдельный запрос к history API
desc = re.sub(r"<[^>]+>", "", task["description"]).strip()
if desc:
if len(desc) > 800:
desc = desc[:800] + ""
lines += ["", f"💬 {desc}"]
# Последний комментарий — запрашиваем из Intradesk API
comment = await get_task_last_comment(task["id"]) comment = await get_task_last_comment(task["id"])
if comment and comment.get("text"): if comment and comment.get("text"):
ctext = comment["text"] ctext = comment["text"]

View file

@ -9,19 +9,23 @@ async def bot_send(
user_id: int | None, user_id: int | None,
text: str, text: str,
attachments=None, attachments=None,
) -> None: ) -> str | None:
"""Отправить новое сообщение.""" """Отправить новое сообщение. Возвращает message_id (mid) или None."""
kw: dict = {"chat_id": chat_id, "text": text} kw: dict = {"chat_id": chat_id, "text": text}
if attachments is not None: if attachments is not None:
kw["attachments"] = attachments kw["attachments"] = attachments
await bot.send_message(**kw) result = await bot.send_message(**kw)
mid: str | None = None
if result and result.message and result.message.body:
mid = result.message.body.mid
await log_message( await log_message(
direction="out", direction="out",
max_user_id=user_id, max_user_id=user_id,
max_chat_id=chat_id, max_chat_id=chat_id,
message_id=None, message_id=mid,
text=text, text=text,
) )
return mid
async def bot_edit( async def bot_edit(

Binary file not shown.

Binary file not shown.

View file

@ -60,6 +60,88 @@ async def get_task_last_comment(task_id: int) -> dict | None:
return None return None
async def get_client_open_tasks(
client_id: int,
page: int = 0,
page_size: int = 5,
) -> dict:
"""
Получить незакрытые заявки клиента из Intradesk API.
Возвращает {"tasks": [...], "total": int}.
Каждая задача содержит поля из OData + "last_comment" (dict или None).
"""
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
tasks = []
for t in raw_tasks:
status_id = t.get("status")
last_comment = _extract_last_comment_from_lifetime(t.get("lifetime"))
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,
})
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[:10] # "2026-03-26"
return {
"text": ev.get("stringvalue") or "",
"author": entry.get("username") or "",
"date": short_date,
}
return None
async def create_intradesk_task( async def create_intradesk_task(
name: str, name: str,
description: str, description: str,