норм
This commit is contained in:
parent
547a4a1dcb
commit
7fab711886
8 changed files with 322 additions and 84 deletions
|
|
@ -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\\)\\)\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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="")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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.
82
external/intradesk_api.py
vendored
82
external/intradesk_api.py
vendored
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue