intra_max_chatbot/bot/handlers/create_ticket.py
2026-04-03 00:30:24 +03:00

529 lines
20 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.

"""
Обработчики создания заявки.
Поток:
1. "Создать заявку" → waiting_description
2. Первое сообщение → сохранение описания, collecting_attachments
3. Доп. сообщения → накопление вложений
4. "Готово" → предпросмотр + кнопки подтверждения, confirming
5a. "Подтвердить" → сохранение в БД + Intradesk, сообщение с номером, главное меню
5b. "Продолжить редактирование" → обратно в collecting_attachments
5c. "Отменить создание" → очистка состояния, главное меню
"""
from maxapi.context import BaseContext
from maxapi.dispatcher import Router
from maxapi.enums.attachment import AttachmentType
from maxapi.filters import F
from maxapi.types.attachments.attachment import (
OtherAttachmentPayload,
PhotoAttachmentPayload,
)
from maxapi.types.updates.message_callback import MessageCallback
from maxapi.types.updates.message_created import MessageCreated
from bot.flow_config import PAYLOADS, TRANSITIONS
from bot.handlers.menu import build_main_menu_text
from bot.keyboards import confirm_ticket_kb, done_ticket_kb, main_menu_kb
from bot.logging_setup import get_logger
from bot.nav_helper import nav_set
from bot.send_helper import bot_send
from bot.texts import MenuTexts, TicketTexts
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 database.queries.ticket_attachments import add_attachment
from database.queries.tickets import create_ticket, set_intradesk_task_id
from external.intradesk_api import create_intradesk_task, invalidate_client_cache, upload_attachments
logger = get_logger("bot.handlers.create_ticket")
router = Router(router_id="create_ticket")
async def cleanup_ticket_flow(bot, context: BaseContext) -> None:
"""
Удалить висящие сообщения незавершённого потока создания заявки.
Вызывается из любого обработчика, который прерывает этот поток
(главное меню, список заявок, повторное нажатие «Создать заявку»).
"""
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
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 = {
AttachmentType.IMAGE,
AttachmentType.VIDEO,
AttachmentType.AUDIO,
AttachmentType.FILE,
}
def _extract_attachments(event: MessageCreated) -> list[dict]:
"""Извлекает метаданные вложений из сообщения."""
result = []
body = event.message.body
if not body or not body.attachments:
return result
for att in body.attachments:
att_type = getattr(att, "type", None)
if att_type not in _MEDIA_TYPES:
continue
url: str | None = None
filename: str | None = None
duration: int | None = None
payload = getattr(att, "payload", None)
if isinstance(payload, PhotoAttachmentPayload):
url = payload.url
elif isinstance(payload, OtherAttachmentPayload):
url = payload.url
filename = (
getattr(payload, "filename", None)
or getattr(payload, "name", None)
or getattr(payload, "title", None)
)
if att_type == AttachmentType.AUDIO:
duration = (
getattr(payload, "duration", None)
or getattr(payload, "duration_ms", None)
)
result.append({"type": str(att_type), "url": url, "filename": filename, "duration": duration})
return result
def _att_label(att: dict) -> str:
"""Человекочитаемое описание вложения для предпросмотра."""
t = att.get("type", "")
if AttachmentType.IMAGE.value in t or "PHOTO" in t.upper():
return TicketTexts.ATT_PHOTO
if AttachmentType.VIDEO.value in t:
return TicketTexts.ATT_VIDEO
if AttachmentType.AUDIO.value in t:
dur = att.get("duration")
if dur:
mins, secs = divmod(int(dur), 60)
return TicketTexts.ATT_AUDIO_DUR.format(mins=mins, secs=secs)
return TicketTexts.ATT_AUDIO
if AttachmentType.FILE.value in t:
fname = att.get("filename")
return TicketTexts.ATT_FILE.format(fname=fname) if fname else TicketTexts.ATT_FILE_UNNAMED
return TicketTexts.ATT_OTHER
def _preview_text(org_name: str | None, description: str, pending_atts: list[dict]) -> str:
"""Формирует текст предпросмотра заявки."""
task_name = org_name or description[:80]
lines = [
TicketTexts.PREVIEW_HEADER,
"",
f"📄 {task_name}",
"",
description[:500] + ("" if len(description) > 500 else ""),
]
if pending_atts:
lines += ["", TicketTexts.PREVIEW_ATTACHMENTS_HEADER]
for att in pending_atts:
lines.append(f"{_att_label(att)}")
lines += ["", TicketTexts.PREVIEW_CONFIRM_PROMPT]
return "\n".join(lines)
# ── Шаг 1: начало создания ────────────────────────────────────────────────────
@router.message_callback(F.callback.payload == PAYLOADS.CREATE_TICKET)
async def cb_create_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
# Зачищаем предыдущий незавершённый поток, если был
await cleanup_ticket_flow(event.bot, context)
await context.set_state(TRANSITIONS["ticket:start"])
await context.set_data({"pending_attachments": [], "flow_message_ids": []})
logger.info("📝 create_ticket started | user_id=%s", bot_user["max_user_id"])
await event.answer(notification="")
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=TicketTexts.ENTER_DESCRIPTION,
attachments=[done_ticket_kb()],
)
await _track_mid(context, mid)
# ── Шаг 2: первое сообщение с описанием ──────────────────────────────────────
@router.message_created(states=TRANSITIONS["ticket:start"])
async def on_ticket_description(
event: MessageCreated, context: BaseContext
) -> 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 ""
new_atts = _extract_attachments(event)
if not text:
if not new_atts:
# Стикер/локация/etc — просим написать описание
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text=TicketTexts.NEED_TEXT,
attachments=[done_ticket_kb()],
)
await _track_mid(context, mid)
return
# Файл без текста — сохраняем, остаёмся в waiting_description, просим текст
data = await context.get_data()
existing = data.get("pending_attachments") or []
existing.extend(new_atts)
await context.update_data(pending_attachments=existing)
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text=TicketTexts.FILE_RECEIVED_NEED_TEXT,
attachments=[done_ticket_kb()],
)
await _track_mid(context, mid)
return
# Есть текст — объединяем с ранее добавленными вложениями
data = await context.get_data()
existing_atts = data.get("pending_attachments") or []
existing_atts.extend(new_atts)
await context.update_data(description=text, pending_attachments=existing_atts)
await context.set_state(TRANSITIONS["ticket:description_ok"])
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text=TicketTexts.DESCRIPTION_ACCEPTED,
attachments=[done_ticket_kb()],
)
await _track_mid(context, mid)
# ── Шаг 3: дополнительные вложения/текст ─────────────────────────────────────
@router.message_created(states=TRANSITIONS["ticket:description_ok"])
async def on_ticket_attachment(
event: MessageCreated, context: BaseContext
) -> None:
data = await context.get_data()
body = event.message.body
extra_text = (body.text or "").strip() if body else ""
new_atts = _extract_attachments(event)
if extra_text:
current = data.get("description", "")
data["description"] = (current + "\n" + extra_text).strip()
pending = data.get("pending_attachments", [])
pending.extend(new_atts)
data["pending_attachments"] = pending
await context.set_data(data)
# ── Шаг 4: "Готово" → предпросмотр ───────────────────────────────────────────
@router.message_callback(
F.callback.payload == PAYLOADS.DONE_TICKET,
states=[TRANSITIONS["ticket:start"], TRANSITIONS["ticket:description_ok"]],
)
async def cb_done_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
data = await context.get_data()
description = (data.get("description") or "").strip()
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
if not description:
await event.answer(notification=TicketTexts.PREVIEW_NEED_TEXT_NOTIF)
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=TicketTexts.PREVIEW_NEED_TEXT,
attachments=[done_ticket_kb()],
)
return
await event.answer(notification="")
pending_atts = data.get("pending_attachments", [])
org = await get_organization(bot_user["org_id"])
org_name = org["name"] if org else None
await context.set_state(TRANSITIONS["ticket:preview"])
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=_preview_text(org_name, description, pending_atts),
attachments=[confirm_ticket_kb()],
)
await _track_mid(context, mid)
# ── Шаг 5б: "Продолжить редактирование" ──────────────────────────────────────
@router.message_callback(
F.callback.payload == PAYLOADS.EDIT_TICKET,
states=TRANSITIONS["ticket:preview"],
)
async def cb_edit_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await event.answer(notification="")
await context.set_state(TRANSITIONS["ticket:edit"])
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=TicketTexts.CONTINUE_EDITING,
attachments=[done_ticket_kb()],
)
await _track_mid(context, mid)
# ── Шаг 5в: "Отменить создание" ──────────────────────────────────────────────
@router.message_callback(
F.callback.payload == PAYLOADS.CANCEL_TICKET,
states=[
TRANSITIONS["ticket:start"],
TRANSITIONS["ticket:description_ok"],
TRANSITIONS["ticket:preview"],
],
)
async def cb_cancel_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await event.answer(notification=TicketTexts.CANCEL_NOTIF)
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
# Удаляем все сообщения потока + текущее сообщение с кнопками
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()
loading_mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=MenuTexts.LOADING,
)
menu_text = await build_main_menu_text(bot_user)
edited = False
if loading_mid:
try:
await event.bot.edit_message(
message_id=loading_mid,
text=menu_text,
attachments=[main_menu_kb()],
)
edited = True
await nav_set(context, loading_mid)
except Exception:
pass
if not edited:
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=menu_text,
attachments=[main_menu_kb()],
)
await nav_set(context, mid)
# ── Шаг 5а: "Подтвердить" → создание ─────────────────────────────────────────
@router.message_callback(
F.callback.payload == PAYLOADS.CONFIRM_TICKET,
states=TRANSITIONS["ticket:preview"],
)
async def cb_confirm_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
# Защита от двойного нажатия: читаем данные и сбрасываем состояние атомарно.
# Повторный tap уже не пройдёт фильтр states=confirming.
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()
description = (data.get("description") or "").strip()
pending_atts = data.get("pending_attachments", [])
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
loading_mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=TicketTexts.SENDING,
)
# ── Загружаем вложения в Intradesk ────────────────────────────────────────
if pending_atts:
pending_atts = await upload_attachments(pending_atts, task_id=0, target="Description")
# ── Данные организации ────────────────────────────────────────────────────
org = await get_organization(bot_user["org_id"])
org_name = org["name"] if org else None
intradesk_id: int | None = org.get("intradesk_id") if org else None
contact_person_id: int | None = None
if intradesk_id and bot_user.get("phone"):
db = get_db()
contact_person_id = await get_intradesk_contact_id_for_phone(
db, intradesk_id, bot_user["phone"]
)
# ── Сохраняем локально ────────────────────────────────────────────────────
ticket_id = await create_ticket(
org_id=bot_user["org_id"],
created_by=bot_user["id"],
description=description,
)
for att in pending_atts:
await add_attachment(
ticket_id=ticket_id,
attachment_type=att["type"],
attachment_url=att.get("url"),
)
logger.info(
"✅ ticket created | ticket_id=%s user_id=%s org_id=%s attachments=%s",
ticket_id, bot_user["max_user_id"], bot_user["org_id"], len(pending_atts),
)
# ── Отправка в Intradesk (ждём, чтобы получить номер) ────────────────────
task_name = org_name or description[:150]
intradesk_result = await create_intradesk_task(
name=task_name,
description=description,
client_intradesk_id=intradesk_id,
contact_person_id=contact_person_id,
attachments=pending_atts or None,
)
intradesk_number: int | None = None
if intradesk_result:
iid = (
intradesk_result.get("id") or intradesk_result.get("Id")
or intradesk_result.get("ID")
)
intradesk_number = (
intradesk_result.get("tasknumber") or intradesk_result.get("number")
or intradesk_result.get("Number") or intradesk_result.get("TaskNumber")
)
if iid:
await set_intradesk_task_id(ticket_id, int(iid))
logger.info(
"Intradesk task created: id=%s number=%s for ticket_id=%s",
iid, intradesk_number, ticket_id,
)
if intradesk_id:
invalidate_client_cache(intradesk_id)
# ── Итоговое сообщение ────────────────────────────────────────────────────
lines = [TicketTexts.CREATED]
if intradesk_number:
lines.append(TicketTexts.CREATED_NUMBER.format(number=intradesk_number))
lines += ["", f"📄 {task_name}", ""]
lines.append(description[:500] + ("" if len(description) > 500 else ""))
if pending_atts:
lines += ["", TicketTexts.PREVIEW_ATTACHMENTS_HEADER]
for att in pending_atts:
lines.append(f"{_att_label(att)}")
lines += ["", TicketTexts.CREATED_CONTACT]
final_text = "\n".join(lines)
edited = False
if loading_mid:
try:
await event.bot.edit_message(
message_id=loading_mid,
text=final_text,
attachments=[main_menu_kb()],
)
edited = True
await nav_set(context, loading_mid)
except Exception:
pass
if not edited:
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=final_text,
attachments=[main_menu_kb()],
)
await nav_set(context, mid)