исправление чего-то

This commit is contained in:
dv 2026-03-26 15:19:38 +03:00
parent 9f32da9a0a
commit 547a4a1dcb
11 changed files with 298 additions and 86 deletions

View file

@ -2,13 +2,14 @@
Обработчики создания заявки.
Поток:
1. Нажатие "Создать заявку" переход в waiting_description
2. Первое сообщение с текстом сохранение описания, переход в collecting_attachments
3. Дополнительные сообщения накопление вложений
4. Нажатие "Готово" сохранение заявки в БД, подтверждение
1. "Создать заявку" waiting_description
2. Первое сообщение сохранение описания, collecting_attachments
3. Доп. сообщения накопление вложений
4. "Готово" предпросмотр + кнопки подтверждения, confirming
5a. "Подтвердить" сохранение в БД + Intradesk, сообщение с номером, главное меню
5b. "Продолжить редактирование" обратно в collecting_attachments
5c. "Отменить создание" очистка состояния, главное меню
"""
import asyncio
from maxapi.context import BaseContext
from maxapi.dispatcher import Router
from maxapi.enums.attachment import AttachmentType
@ -20,19 +21,20 @@ from maxapi.types.attachments.attachment import (
from maxapi.types.updates.message_callback import MessageCallback
from maxapi.types.updates.message_created import MessageCreated
from bot.keyboards import done_ticket_kb, main_menu_kb
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.send_helper import bot_send
from bot.states import TicketStates
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 bot.states import TicketStates
logger = get_logger("bot.handlers.create_ticket")
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
logger = get_logger("bot.handlers.create_ticket")
router = Router(router_id="create_ticket")
_MEDIA_TYPES = {
@ -53,14 +55,67 @@ def _extract_attachments(event: MessageCreated) -> list[dict]:
att_type = getattr(att, "type", None)
if att_type not in _MEDIA_TYPES:
continue
url = None
url: str | None = None
filename: str | None = None
duration: int | None = None
payload = getattr(att, "payload", None)
if isinstance(payload, (PhotoAttachmentPayload, OtherAttachmentPayload)):
if isinstance(payload, PhotoAttachmentPayload):
url = payload.url
result.append({"type": str(att_type), "url": 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 "🖼 Фото"
if AttachmentType.VIDEO.value in t:
return "📹 Видео"
if AttachmentType.AUDIO.value in t:
dur = att.get("duration")
if dur:
mins, secs = divmod(int(dur), 60)
return f"🎤 Голосовое ({mins:02d}:{secs:02d})"
return "🎤 Голосовое"
if AttachmentType.FILE.value in t:
fname = att.get("filename")
return f"📎 {fname}" if fname else "📎 Файл"
return "📎 Вложение"
def _preview_text(org_name: str | None, description: str, pending_atts: list[dict]) -> str:
"""Формирует текст предпросмотра заявки."""
task_name = org_name or description[:80]
lines = [
"📋 Предпросмотр заявки:",
"",
f"📄 {task_name}",
"",
description[:500] + ("" if len(description) > 500 else ""),
]
if pending_atts:
lines += ["", "📎 Вложения:"]
for att in pending_atts:
lines.append(f"{_att_label(att)}")
lines += ["", "Всё верно? Подтвердите или вернитесь к редактированию."]
return "\n".join(lines)
# ── Шаг 1: начало создания ────────────────────────────────────────────────────
@router.message_callback(F.callback.payload == "create_ticket")
async def cb_create_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
@ -87,6 +142,8 @@ async def cb_create_ticket(
)
# ── Шаг 2: первое сообщение с описанием ──────────────────────────────────────
@router.message_created(states=TicketStates.waiting_description)
async def on_ticket_description(
event: MessageCreated, context: BaseContext
@ -99,10 +156,7 @@ async def on_ticket_description(
text = (body.text or "").strip() if body else ""
attachments = _extract_attachments(event)
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 bot_send(
@ -117,6 +171,8 @@ async def on_ticket_description(
)
# ── Шаг 3: дополнительные вложения/текст ─────────────────────────────────────
@router.message_created(states=TicketStates.collecting_attachments)
async def on_ticket_attachment(
event: MessageCreated, context: BaseContext
@ -133,10 +189,11 @@ async def on_ticket_attachment(
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 == "done_ticket",
states=[TicketStates.waiting_description, TicketStates.collecting_attachments],
@ -162,33 +219,104 @@ async def cb_done_ticket(
)
return
ticket_id = await create_ticket(
org_id=bot_user["org_id"],
created_by=bot_user["id"],
description=description,
)
await event.answer(notification="")
pending_atts = data.get("pending_attachments", [])
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),
org = await get_organization(bot_user["org_id"])
org_name = org["name"] if org else None
await context.set_state(TicketStates.confirming)
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()],
)
for att in pending_atts:
await add_attachment(
ticket_id=ticket_id,
attachment_type=att["type"],
attachment_url=att.get("url"),
# ── Шаг 5б: "Продолжить редактирование" ──────────────────────────────────────
@router.message_callback(
F.callback.payload == "edit_ticket",
states=TicketStates.confirming,
)
async def cb_edit_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await event.answer(notification="")
await context.set_state(TicketStates.collecting_attachments)
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=(
"✏️ Продолжайте добавлять описание или вложения.\n"
"Нажмите «Готово» когда будете готовы."
),
attachments=[done_ticket_kb()],
)
# ── Шаг 5в: "Отменить создание" ──────────────────────────────────────────────
@router.message_callback(
F.callback.payload == "cancel_ticket",
states=TicketStates.confirming,
)
async def cb_cancel_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await event.answer(notification="Создание отменено")
await context.clear()
await event.answer(notification="Заявка создана!")
# ── Отправка в Intradesk ──────────────────────────────────────────────────
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=await build_main_menu_text(bot_user),
attachments=[main_menu_kb()],
)
# ── Шаг 5а: "Подтвердить" → создание ─────────────────────────────────────────
@router.message_callback(
F.callback.payload == "confirm_ticket",
states=TicketStates.confirming,
)
async def cb_confirm_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
# Защита от двойного нажатия: читаем данные и сбрасываем состояние атомарно.
# Повторный tap уже не пройдёт фильтр states=confirming.
data = await context.get_data()
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
await event.answer(notification="Отправляем заявку…")
# ── Данные организации ────────────────────────────────────────────────────
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
# Находим id контактного лица в Intradesk по телефону пользователя
contact_person_id: int | None = None
if intradesk_id and bot_user.get("phone"):
db = get_db()
@ -196,48 +324,75 @@ async def cb_done_ticket(
db, intradesk_id, bot_user["phone"]
)
# Ссылки на вложения (Max CDN) — добавятся в описание задачи
# ── Сохраняем локально ────────────────────────────────────────────────────
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]
att_urls = [a["url"] for a in pending_atts if a.get("url")]
async def _create_in_intradesk():
result = await create_intradesk_task(
name=description[:150],
intradesk_result = await create_intradesk_task(
name=task_name,
description=description,
client_intradesk_id=intradesk_id,
contact_person_id=contact_person_id,
attachment_urls=att_urls or None,
)
if result:
iid = result.get("id")
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 for ticket_id=%s", iid, ticket_id)
asyncio.create_task(_create_in_intradesk())
# ── Подтверждение пользователю ────────────────────────────────────────────
att_count = len(pending_atts)
confirm_text = (
f"✅ Заявка успешно создана!\n"
f"Описание: {description[:200]}{'' if len(description) > 200 else ''}"
logger.info(
"Intradesk task created: id=%s number=%s for ticket_id=%s",
iid, intradesk_number, ticket_id,
)
if att_count:
confirm_text += f"\nВложений: {att_count}"
confirm_text += "\n\nНаши специалисты свяжутся с вами в ближайшее время."
# ── Итоговое сообщение ────────────────────────────────────────────────────
lines = ["✅ Заявка успешно создана!"]
if intradesk_number:
lines.append(f"Номер заявки: #{intradesk_number}")
lines += ["", f"📄 {task_name}", ""]
lines.append(description[:500] + ("" if len(description) > 500 else ""))
if pending_atts:
lines += ["", "📎 Вложения:"]
for att in pending_atts:
lines.append(f"{_att_label(att)}")
lines += ["", "Наши специалисты свяжутся с вами в ближайшее время."]
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=confirm_text,
text="\n".join(lines),
)
menu_text = f"🏠 {org_name}\nВыберите действие:" if org_name else "🏠 Главное меню\nВыберите действие:"
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=menu_text,
text=await build_main_menu_text(bot_user),
attachments=[main_menu_kb()],
)

View file

@ -6,22 +6,43 @@ 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
router = Router(router_id="menu")
async def _main_menu_text(bot_user: dict) -> str:
async def build_main_menu_text(bot_user: dict) -> str:
"""Формирует текст главного меню с именем объекта и статистикой заявок."""
org_id = bot_user.get("org_id")
org_name: str | None = None
if org_id:
org = await get_organization(org_id)
org_name = org["name"] if org else None
else:
org_name = None
lines: list[str] = []
if org_name:
return f"🏠 {org_name}\nВыберите действие:"
return "🏠 Главное меню\nВыберите действие:"
lines.append(f"🏢 {org_name}")
else:
lines.append("🏠 Главное меню")
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 total == 0:
lines.append("Заявок пока нет.")
else:
lines.append(f"Заявок: {total} (активных: {active})")
lines.append("\nВыберите действие:")
return "\n".join(lines)
@router.message_callback(F.callback.payload == "main_menu")
@ -31,7 +52,7 @@ async def cb_main_menu(
await context.set_state(None)
await event.answer(notification="")
text = await _main_menu_text(bot_user)
text = await build_main_menu_text(bot_user)
await bot_reply(
event,
text=text,

View file

@ -30,7 +30,6 @@ from database.queries.bot_users import (
)
from database.queries.org_users import find_by_phone
from database.queries.message_log import log_message
from database.queries.organizations import get_organization
router = Router(router_id="start")
@ -224,13 +223,8 @@ async def on_contact_shared(
)
await context.set_state(None)
org = await get_organization(org_user["org_id"])
org_name = org["name"] if org else None
menu_text = (
f"✅ Вы успешно авторизованы!\n\n🏠 {org_name}\nВыберите действие:"
if org_name
else "✅ Вы успешно авторизованы! Выберите действие:"
)
from bot.handlers.menu import build_main_menu_text # local import to avoid circular
menu_text = "✅ Вы успешно авторизованы!\n\n" + await build_main_menu_text(bot_user)
await bot_send(
event.bot,

View file

@ -15,7 +15,7 @@ from maxapi.filters import F
from maxapi.types.updates.message_callback import MessageCallback
from bot.filters import PayloadStartsWith
from bot.keyboards import intradesk_tasks_list_kb, iticket_detail_kb
from bot.keyboards import intradesk_tasks_list_kb, iticket_detail_kb, menu_only_kb
from bot.send_helper import bot_reply
from database.connection import get_db
from database.queries.intradesk import (
@ -70,11 +70,11 @@ async def cb_view_tickets(
) -> None:
await event.answer(notification="")
text, tasks, total = await _tasks_page_text(bot_user["org_id"], 0)
kb = intradesk_tasks_list_kb(tasks, page=0, total=total) if tasks else None
kb = intradesk_tasks_list_kb(tasks, page=0, total=total) if tasks else menu_only_kb()
await bot_reply(
event,
text=text,
attachments=[kb] if kb else None,
attachments=[kb],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)
@ -90,11 +90,11 @@ async def cb_itickets_page(
except (IndexError, ValueError):
page = 0
text, tasks, total = await _tasks_page_text(bot_user["org_id"], page)
kb = intradesk_tasks_list_kb(tasks, page=page, total=total) if tasks else None
kb = intradesk_tasks_list_kb(tasks, page=page, total=total) if tasks else menu_only_kb()
await bot_reply(
event,
text=text,
attachments=[kb] if kb else None,
attachments=[kb],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)

View file

@ -23,6 +23,26 @@ def contact_request_kb() -> Attachment:
)
def confirm_ticket_kb() -> Attachment:
"""Клавиатура подтверждения создания заявки."""
return (
InlineKeyboardBuilder()
.row(CallbackButton(text="✅ Подтвердить", payload="confirm_ticket"))
.row(CallbackButton(text="✏️ Продолжить редактирование", payload="edit_ticket"))
.row(CallbackButton(text="❌ Отменить создание", payload="cancel_ticket"))
.as_markup()
)
def menu_only_kb() -> Attachment:
"""Клавиатура с единственной кнопкой возврата в главное меню."""
return (
InlineKeyboardBuilder()
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
.as_markup()
)
def done_ticket_kb() -> Attachment:
return (
InlineKeyboardBuilder()

View file

@ -8,3 +8,4 @@ class AuthStates(StatesGroup):
class TicketStates(StatesGroup):
waiting_description = State() # Ожидаем первое сообщение с описанием
collecting_attachments = State() # Накапливаем доп. файлы/текст
confirming = State() # Предпросмотр — ждём подтверждения

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -330,6 +330,23 @@ async def count_tasks_for_local_org(
return row[0] if row else 0
async def count_active_tasks_for_local_org(
db: aiosqlite.Connection, local_org_id: int
) -> int:
"""Количество незакрытых заявок организации (closed_at IS NULL)."""
async with db.execute(
"""
SELECT COUNT(*)
FROM intradesk_tasks it
JOIN organizations o ON o.intradesk_id = it.client_id
WHERE o.id = ? AND it.closed_at IS NULL
""",
(local_org_id,),
) as cur:
row = await cur.fetchone()
return row[0] if row else 0
async def get_task_for_local_org(
db: aiosqlite.Connection, task_id: int, local_org_id: int
) -> dict | None:

View file

@ -138,11 +138,15 @@ async def create_intradesk_task(
)
return None
result = resp.json()
logger.info(
"create_intradesk_task ok: id=%s number=%s",
result.get("id"),
result.get("tasknumber") or result.get("number"),
# 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)