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

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 1. "Создать заявку" waiting_description
2. Первое сообщение с текстом сохранение описания, переход в collecting_attachments 2. Первое сообщение сохранение описания, collecting_attachments
3. Дополнительные сообщения накопление вложений 3. Доп. сообщения накопление вложений
4. Нажатие "Готово" сохранение заявки в БД, подтверждение 4. "Готово" предпросмотр + кнопки подтверждения, confirming
5a. "Подтвердить" сохранение в БД + Intradesk, сообщение с номером, главное меню
5b. "Продолжить редактирование" обратно в collecting_attachments
5c. "Отменить создание" очистка состояния, главное меню
""" """
import asyncio
from maxapi.context import BaseContext from maxapi.context import BaseContext
from maxapi.dispatcher import Router from maxapi.dispatcher import Router
from maxapi.enums.attachment import AttachmentType 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_callback import MessageCallback
from maxapi.types.updates.message_created import MessageCreated 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.logging_setup import get_logger
from bot.send_helper import bot_send from bot.send_helper import bot_send
from bot.states import TicketStates
from database.connection import get_db from database.connection import get_db
from database.queries.intradesk import get_intradesk_contact_id_for_phone from database.queries.intradesk import get_intradesk_contact_id_for_phone
from database.queries.organizations import get_organization 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.ticket_attachments import add_attachment
from database.queries.tickets import create_ticket, set_intradesk_task_id from database.queries.tickets import create_ticket, set_intradesk_task_id
from external.intradesk_api import create_intradesk_task from external.intradesk_api import create_intradesk_task
logger = get_logger("bot.handlers.create_ticket")
router = Router(router_id="create_ticket") router = Router(router_id="create_ticket")
_MEDIA_TYPES = { _MEDIA_TYPES = {
@ -53,14 +55,67 @@ def _extract_attachments(event: MessageCreated) -> list[dict]:
att_type = getattr(att, "type", None) att_type = getattr(att, "type", None)
if att_type not in _MEDIA_TYPES: if att_type not in _MEDIA_TYPES:
continue continue
url = None url: str | None = None
filename: str | None = None
duration: int | None = None
payload = getattr(att, "payload", None) payload = getattr(att, "payload", None)
if isinstance(payload, (PhotoAttachmentPayload, OtherAttachmentPayload)): if isinstance(payload, PhotoAttachmentPayload):
url = payload.url 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 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") @router.message_callback(F.callback.payload == "create_ticket")
async def cb_create_ticket( async def cb_create_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict event: MessageCallback, context: BaseContext, bot_user: dict
@ -87,6 +142,8 @@ async def cb_create_ticket(
) )
# ── Шаг 2: первое сообщение с описанием ──────────────────────────────────────
@router.message_created(states=TicketStates.waiting_description) @router.message_created(states=TicketStates.waiting_description)
async def on_ticket_description( async def on_ticket_description(
event: MessageCreated, context: BaseContext event: MessageCreated, context: BaseContext
@ -99,10 +156,7 @@ async def on_ticket_description(
text = (body.text or "").strip() if body else "" text = (body.text or "").strip() if body else ""
attachments = _extract_attachments(event) attachments = _extract_attachments(event)
await context.update_data( await context.update_data(description=text, pending_attachments=attachments)
description=text,
pending_attachments=attachments,
)
await context.set_state(TicketStates.collecting_attachments) await context.set_state(TicketStates.collecting_attachments)
await bot_send( await bot_send(
@ -117,6 +171,8 @@ async def on_ticket_description(
) )
# ── Шаг 3: дополнительные вложения/текст ─────────────────────────────────────
@router.message_created(states=TicketStates.collecting_attachments) @router.message_created(states=TicketStates.collecting_attachments)
async def on_ticket_attachment( async def on_ticket_attachment(
event: MessageCreated, context: BaseContext event: MessageCreated, context: BaseContext
@ -133,10 +189,11 @@ async def on_ticket_attachment(
pending = data.get("pending_attachments", []) pending = data.get("pending_attachments", [])
pending.extend(new_atts) pending.extend(new_atts)
data["pending_attachments"] = pending data["pending_attachments"] = pending
await context.set_data(data) await context.set_data(data)
# ── Шаг 4: "Готово" → предпросмотр ───────────────────────────────────────────
@router.message_callback( @router.message_callback(
F.callback.payload == "done_ticket", F.callback.payload == "done_ticket",
states=[TicketStates.waiting_description, TicketStates.collecting_attachments], states=[TicketStates.waiting_description, TicketStates.collecting_attachments],
@ -162,33 +219,104 @@ async def cb_done_ticket(
) )
return return
ticket_id = await create_ticket( await event.answer(notification="")
org_id=bot_user["org_id"],
created_by=bot_user["id"],
description=description,
)
pending_atts = data.get("pending_attachments", []) pending_atts = data.get("pending_attachments", [])
logger.info(
"✅ ticket created | ticket_id=%s user_id=%s org_id=%s attachments=%s", org = await get_organization(bot_user["org_id"])
ticket_id, bot_user["max_user_id"], bot_user["org_id"], len(pending_atts), 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( # ── Шаг 5б: "Продолжить редактирование" ──────────────────────────────────────
ticket_id=ticket_id,
attachment_type=att["type"], @router.message_callback(
attachment_url=att.get("url"), 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 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 = await get_organization(bot_user["org_id"])
org_name = org["name"] if org else None org_name = org["name"] if org else None
intradesk_id: int | None = org.get("intradesk_id") if org else None intradesk_id: int | None = org.get("intradesk_id") if org else None
# Находим id контактного лица в Intradesk по телефону пользователя
contact_person_id: int | None = None contact_person_id: int | None = None
if intradesk_id and bot_user.get("phone"): if intradesk_id and bot_user.get("phone"):
db = get_db() db = get_db()
@ -196,48 +324,75 @@ async def cb_done_ticket(
db, intradesk_id, bot_user["phone"] 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")] att_urls = [a["url"] for a in pending_atts if a.get("url")]
async def _create_in_intradesk(): intradesk_result = await create_intradesk_task(
result = await create_intradesk_task( name=task_name,
name=description[:150],
description=description, description=description,
client_intradesk_id=intradesk_id, client_intradesk_id=intradesk_id,
contact_person_id=contact_person_id, contact_person_id=contact_person_id,
attachment_urls=att_urls or None, 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: if iid:
await set_intradesk_task_id(ticket_id, int(iid)) await set_intradesk_task_id(ticket_id, int(iid))
logger.info("Intradesk task created: id=%s for ticket_id=%s", iid, ticket_id) logger.info(
"Intradesk task created: id=%s number=%s for ticket_id=%s",
asyncio.create_task(_create_in_intradesk()) iid, intradesk_number, ticket_id,
# ── Подтверждение пользователю ────────────────────────────────────────────
att_count = len(pending_atts)
confirm_text = (
f"✅ Заявка успешно создана!\n"
f"Описание: {description[:200]}{'' if len(description) > 200 else ''}"
) )
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( 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=confirm_text, text="\n".join(lines),
) )
menu_text = f"🏠 {org_name}\nВыберите действие:" if org_name else "🏠 Главное меню\nВыберите действие:"
await bot_send( 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=menu_text, text=await build_main_menu_text(bot_user),
attachments=[main_menu_kb()], attachments=[main_menu_kb()],
) )

View file

@ -6,22 +6,43 @@ from maxapi.context import BaseContext
from bot.keyboards import main_menu_kb from bot.keyboards import main_menu_kb
from bot.send_helper import bot_reply 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 database.queries.organizations import get_organization
router = Router(router_id="menu") 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_id = bot_user.get("org_id")
org_name: str | None = None
if org_id: if org_id:
org = await get_organization(org_id) org = await get_organization(org_id)
org_name = org["name"] if org else None org_name = org["name"] if org else None
else:
org_name = None
lines: list[str] = []
if org_name: if org_name:
return f"🏠 {org_name}\nВыберите действие:" lines.append(f"🏢 {org_name}")
return "🏠 Главное меню\nВыберите действие:" 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") @router.message_callback(F.callback.payload == "main_menu")
@ -31,7 +52,7 @@ async def cb_main_menu(
await context.set_state(None) await context.set_state(None)
await event.answer(notification="") await event.answer(notification="")
text = await _main_menu_text(bot_user) text = await build_main_menu_text(bot_user)
await bot_reply( await bot_reply(
event, event,
text=text, 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.org_users import find_by_phone
from database.queries.message_log import log_message from database.queries.message_log import log_message
from database.queries.organizations import get_organization
router = Router(router_id="start") router = Router(router_id="start")
@ -224,13 +223,8 @@ async def on_contact_shared(
) )
await context.set_state(None) await context.set_state(None)
org = await get_organization(org_user["org_id"]) from bot.handlers.menu import build_main_menu_text # local import to avoid circular
org_name = org["name"] if org else None menu_text = "✅ Вы успешно авторизованы!\n\n" + await build_main_menu_text(bot_user)
menu_text = (
f"✅ Вы успешно авторизованы!\n\n🏠 {org_name}\nВыберите действие:"
if org_name
else "✅ Вы успешно авторизованы! Выберите действие:"
)
await bot_send( await bot_send(
event.bot, event.bot,

View file

@ -15,7 +15,7 @@ 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 from bot.keyboards import intradesk_tasks_list_kb, 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.intradesk import (
@ -70,11 +70,11 @@ async def cb_view_tickets(
) -> None: ) -> None:
await event.answer(notification="") await event.answer(notification="")
text, tasks, total = await _tasks_page_text(bot_user["org_id"], 0) 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( await bot_reply(
event, event,
text=text, text=text,
attachments=[kb] if kb else None, attachments=[kb],
user_id=bot_user["max_user_id"], user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None, chat_id=event.message.recipient.chat_id if event.message else None,
) )
@ -90,11 +90,11 @@ async def cb_itickets_page(
except (IndexError, ValueError): except (IndexError, ValueError):
page = 0 page = 0
text, tasks, total = await _tasks_page_text(bot_user["org_id"], page) 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( await bot_reply(
event, event,
text=text, text=text,
attachments=[kb] if kb else None, attachments=[kb],
user_id=bot_user["max_user_id"], user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None, 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: def done_ticket_kb() -> Attachment:
return ( return (
InlineKeyboardBuilder() InlineKeyboardBuilder()

View file

@ -8,3 +8,4 @@ class AuthStates(StatesGroup):
class TicketStates(StatesGroup): class TicketStates(StatesGroup):
waiting_description = State() # Ожидаем первое сообщение с описанием waiting_description = State() # Ожидаем первое сообщение с описанием
collecting_attachments = 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 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( async def get_task_for_local_org(
db: aiosqlite.Connection, task_id: int, local_org_id: int db: aiosqlite.Connection, task_id: int, local_org_id: int
) -> dict | None: ) -> dict | None:

View file

@ -138,11 +138,15 @@ async def create_intradesk_task(
) )
return None return None
result = resp.json() result = resp.json()
logger.info( # API может вернуть как "Number"/"Id" (PascalCase), так и "number"/"id"
"create_intradesk_task ok: id=%s number=%s", task_id = result.get("id") or result.get("Id") or result.get("ID")
result.get("id"), task_number = (
result.get("tasknumber") or result.get("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 return result
except Exception as exc: except Exception as exc:
logger.exception("create_intradesk_task error: %s", exc) logger.exception("create_intradesk_task error: %s", exc)