+-
This commit is contained in:
parent
2693dc496d
commit
c3d848153e
21 changed files with 861 additions and 221 deletions
|
|
@ -10,7 +10,10 @@
|
|||
"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\\)\\)\")",
|
||||
"Bash(python -c \"import maxapi; print\\(maxapi.__file__\\)\")",
|
||||
"Bash(find C:UsersProfessionalAppDataLocalProgramsPythonPython313Libsite-packagesmaxapi -name command.py -o -name *command*)"
|
||||
"Bash(find C:UsersProfessionalAppDataLocalProgramsPythonPython313Libsite-packagesmaxapi -name command.py -o -name *command*)",
|
||||
"Bash(python -m py_compile bot/texts.py bot/flow_config.py bot/keyboards.py bot/handlers/start.py bot/handlers/create_ticket.py bot/handlers/view_tickets.py bot/handlers/menu.py)",
|
||||
"Bash(pip show:*)",
|
||||
"Bash(python -c \"import ast, pathlib; errors = [f''{p}: {e}'' for p in pathlib.Path\\(''bot''\\).rglob\\(''*.py''\\) for e in [None] if not \\(lambda: ast.parse\\(p.read_text\\(encoding=''utf-8''\\)\\)\\)\\(\\)]; print\\(''OK''\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||
from admin.auth import require_admin
|
||||
from admin.templates_env import templates
|
||||
from database.queries.bot_users import (
|
||||
change_org_bot_user,
|
||||
delete_bot_user,
|
||||
get_all_bot_users,
|
||||
reset_bot_user,
|
||||
|
|
@ -46,6 +47,12 @@ async def unblock_user(user_id: int, org_id: int = Form(...)):
|
|||
return RedirectResponse("/admin/bot-users", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{user_id}/change-org")
|
||||
async def change_org(user_id: int, org_id: int = Form(...)):
|
||||
await change_org_bot_user(user_id=user_id, org_id=org_id)
|
||||
return RedirectResponse("/admin/bot-users", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset")
|
||||
async def reset_user(user_id: int):
|
||||
"""Сбрасывает авторизацию — пользователь должен заново авторизоваться через бот."""
|
||||
|
|
|
|||
|
|
@ -65,6 +65,19 @@
|
|||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# Сменить организацию — только для авторизованных и незаблокированных #}
|
||||
{% if u.is_authorized and not u.is_blocked %}
|
||||
<form action="/admin/bot-users/{{ u.id }}/change-org" method="post"
|
||||
style="display:flex;gap:4px;align-items:center">
|
||||
<select name="org_id" required style="width:auto">
|
||||
{% for org in orgs %}
|
||||
<option value="{{ org.id }}" {% if u.org_id == org.id %}selected{% endif %}>{{ org.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">Сменить орг.</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# Сбросить авторизацию — для авторизованных и заблокированных (повторная auth через бот) #}
|
||||
{% if u.is_authorized or u.is_blocked %}
|
||||
<form action="/admin/bot-users/{{ u.id }}/reset" method="post">
|
||||
|
|
|
|||
96
bot/flow_config.py
Normal file
96
bot/flow_config.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""
|
||||
Конфигурация FSM-потоков:
|
||||
- PAYLOADS — все callback payload строки (статические и шаблонные)
|
||||
- TRANSITIONS — таблица переходов между состояниями
|
||||
|
||||
Использование переходов в handlers:
|
||||
await context.set_state(TRANSITIONS["ticket:start"])
|
||||
|
||||
Использование payload в keyboards:
|
||||
payload=PAYLOADS.CREATE_TICKET
|
||||
payload=PAYLOADS.ITICKET_DETAIL.format(id=task_id)
|
||||
|
||||
Использование префиксов в фильтрах:
|
||||
PayloadStartsWith(PAYLOADS.PFX_ITICKET_DETAIL)
|
||||
"""
|
||||
from bot.states import AuthStates, CommentStates, TicketStates
|
||||
|
||||
|
||||
class PAYLOADS:
|
||||
"""Callback payload строки для кнопок и фильтров."""
|
||||
|
||||
# Статические payload
|
||||
CREATE_TICKET = "create_ticket"
|
||||
VIEW_TICKETS = "view_tickets"
|
||||
DONE_TICKET = "done_ticket"
|
||||
CONFIRM_TICKET = "confirm_ticket"
|
||||
EDIT_TICKET = "edit_ticket"
|
||||
CANCEL_TICKET = "cancel_ticket"
|
||||
MAIN_MENU = "main_menu"
|
||||
|
||||
# Динамические payload — форматировать через .format(...)
|
||||
TICKET_DETAIL = "ticket_detail:{id}"
|
||||
TICKETS_PAGE = "tickets_page:{page}"
|
||||
ITICKET_DETAIL = "iticket_detail:{id}"
|
||||
ITICKETS_PAGE = "itickets_page:{page}"
|
||||
ITICKET_COMMENT = "iticket_comment:{task_id}"
|
||||
ITICKET_CLOSE = "iticket_close:{task_id}"
|
||||
|
||||
# Quick reply
|
||||
CONFIRM_REPLY = "confirm_reply:{task_id}"
|
||||
CANCEL_REPLY = "cancel_reply"
|
||||
|
||||
# Префиксы для PayloadStartsWith(...)
|
||||
PFX_TICKET_DETAIL = "ticket_detail:"
|
||||
PFX_TICKETS_PAGE = "tickets_page:"
|
||||
PFX_ITICKET_DETAIL = "iticket_detail:"
|
||||
PFX_ITICKETS_PAGE = "itickets_page:"
|
||||
PFX_ITICKET_COMMENT = "iticket_comment:"
|
||||
PFX_ITICKET_CLOSE = "iticket_close:"
|
||||
PFX_CONFIRM_REPLY = "confirm_reply:"
|
||||
|
||||
|
||||
TRANSITIONS: dict[str, object] = {
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────
|
||||
# Новый / не авторизованный пользователь — запрашиваем контакт
|
||||
"auth:new_user": AuthStates.waiting_contact,
|
||||
# Авторизованный пользователь — сбросить в None (главное меню)
|
||||
"auth:authorized": None,
|
||||
# Заблокированный пользователь — сбросить в None
|
||||
"auth:blocked": None,
|
||||
# Контакт найден в белом списке → авторизован → главное меню
|
||||
"auth:contact_found": None,
|
||||
|
||||
# ── Ticket creation ───────────────────────────────────────────────────────
|
||||
# Нажали «Создать заявку»
|
||||
"ticket:start": TicketStates.waiting_description,
|
||||
# Получили первое сообщение с текстом описания
|
||||
"ticket:description_ok": TicketStates.collecting_attachments,
|
||||
# Нажали «Перейти к предпросмотру»
|
||||
"ticket:preview": TicketStates.confirming,
|
||||
# Подтвердили создание заявки
|
||||
"ticket:confirm": None,
|
||||
# Нажали «Продолжить редактирование»
|
||||
"ticket:edit": TicketStates.collecting_attachments,
|
||||
# Отменили создание заявки
|
||||
"ticket:cancel": None,
|
||||
|
||||
# ── Comment ───────────────────────────────────────────────────────────────
|
||||
# Нажали «Оставить комментарий»
|
||||
"comment:start": CommentStates.waiting_text,
|
||||
# Комментарий отправлен (или отменён через «Назад»)
|
||||
"comment:done": None,
|
||||
|
||||
# ── Quick reply ───────────────────────────────────────────────────────────
|
||||
# Свободное сообщение перехвачено — ждём подтверждения от пользователя
|
||||
"reply:pending": TicketStates.pending_confirm,
|
||||
# Подтверждено / отменено — возврат в idle
|
||||
"reply:done": None,
|
||||
"reply:cancel": None,
|
||||
|
||||
# ── Navigation / global ───────────────────────────────────────────────────
|
||||
# Перейти в главное меню
|
||||
"nav:menu": None,
|
||||
# Отменить текущее действие (/cancel)
|
||||
"nav:cancel": None,
|
||||
}
|
||||
|
|
@ -21,12 +21,13 @@ from maxapi.types.attachments.attachment import (
|
|||
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.states import TicketStates
|
||||
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
|
||||
|
|
@ -131,49 +132,49 @@ def _att_label(att: dict) -> str:
|
|||
"""Человекочитаемое описание вложения для предпросмотра."""
|
||||
t = att.get("type", "")
|
||||
if AttachmentType.IMAGE.value in t or "PHOTO" in t.upper():
|
||||
return "🖼 Фото"
|
||||
return TicketTexts.ATT_PHOTO
|
||||
if AttachmentType.VIDEO.value in t:
|
||||
return "📹 Видео"
|
||||
return TicketTexts.ATT_VIDEO
|
||||
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 "🎤 Голосовое"
|
||||
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 f"📎 {fname}" if fname else "📎 Файл"
|
||||
return "📎 Вложение"
|
||||
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 += ["", "📎 Вложения:"]
|
||||
lines += ["", TicketTexts.PREVIEW_ATTACHMENTS_HEADER]
|
||||
for att in pending_atts:
|
||||
lines.append(f" • {_att_label(att)}")
|
||||
lines += ["", "Всё верно? Подтвердите или вернитесь к редактированию."]
|
||||
lines += ["", TicketTexts.PREVIEW_CONFIRM_PROMPT]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Шаг 1: начало создания ────────────────────────────────────────────────────
|
||||
|
||||
@router.message_callback(F.callback.payload == "create_ticket")
|
||||
@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(TicketStates.waiting_description)
|
||||
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="")
|
||||
|
|
@ -186,11 +187,7 @@ async def cb_create_ticket(
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
text=(
|
||||
"📝 Опишите вашу проблему.\n\n"
|
||||
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
|
||||
"Когда закончите — нажмите «👁 Перейти к предпросмотру»."
|
||||
),
|
||||
text=TicketTexts.ENTER_DESCRIPTION,
|
||||
attachments=[done_ticket_kb()],
|
||||
)
|
||||
await _track_mid(context, mid)
|
||||
|
|
@ -198,7 +195,7 @@ async def cb_create_ticket(
|
|||
|
||||
# ── Шаг 2: первое сообщение с описанием ──────────────────────────────────────
|
||||
|
||||
@router.message_created(states=TicketStates.waiting_description)
|
||||
@router.message_created(states=TRANSITIONS["ticket:start"])
|
||||
async def on_ticket_description(
|
||||
event: MessageCreated, context: BaseContext
|
||||
) -> None:
|
||||
|
|
@ -217,7 +214,7 @@ async def on_ticket_description(
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text="❗ Пожалуйста, напишите описание проблемы текстом.",
|
||||
text=TicketTexts.NEED_TEXT,
|
||||
attachments=[done_ticket_kb()],
|
||||
)
|
||||
await _track_mid(context, mid)
|
||||
|
|
@ -231,7 +228,7 @@ async def on_ticket_description(
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text="✅ Вложение принято! Теперь напишите описание проблемы текстом:",
|
||||
text=TicketTexts.FILE_RECEIVED_NEED_TEXT,
|
||||
attachments=[done_ticket_kb()],
|
||||
)
|
||||
await _track_mid(context, mid)
|
||||
|
|
@ -243,16 +240,13 @@ async def on_ticket_description(
|
|||
existing_atts.extend(new_atts)
|
||||
|
||||
await context.update_data(description=text, pending_attachments=existing_atts)
|
||||
await context.set_state(TicketStates.collecting_attachments)
|
||||
await context.set_state(TRANSITIONS["ticket:description_ok"])
|
||||
|
||||
mid = await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text=(
|
||||
"✏️ Описание принято. Вы можете добавить ещё файлы "
|
||||
"или нажмите «👁 Перейти к предпросмотру»."
|
||||
),
|
||||
text=TicketTexts.DESCRIPTION_ACCEPTED,
|
||||
attachments=[done_ticket_kb()],
|
||||
)
|
||||
await _track_mid(context, mid)
|
||||
|
|
@ -260,7 +254,7 @@ async def on_ticket_description(
|
|||
|
||||
# ── Шаг 3: дополнительные вложения/текст ─────────────────────────────────────
|
||||
|
||||
@router.message_created(states=TicketStates.collecting_attachments)
|
||||
@router.message_created(states=TRANSITIONS["ticket:description_ok"])
|
||||
async def on_ticket_attachment(
|
||||
event: MessageCreated, context: BaseContext
|
||||
) -> None:
|
||||
|
|
@ -282,8 +276,8 @@ async def on_ticket_attachment(
|
|||
# ── Шаг 4: "Готово" → предпросмотр ───────────────────────────────────────────
|
||||
|
||||
@router.message_callback(
|
||||
F.callback.payload == "done_ticket",
|
||||
states=[TicketStates.waiting_description, TicketStates.collecting_attachments],
|
||||
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
|
||||
|
|
@ -296,12 +290,12 @@ async def cb_done_ticket(
|
|||
return
|
||||
|
||||
if not description:
|
||||
await event.answer(notification="Сначала опишите проблему!")
|
||||
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="❗ Пожалуйста, сначала напишите описание проблемы.",
|
||||
text=TicketTexts.PREVIEW_NEED_TEXT,
|
||||
attachments=[done_ticket_kb()],
|
||||
)
|
||||
return
|
||||
|
|
@ -312,7 +306,7 @@ async def cb_done_ticket(
|
|||
org = await get_organization(bot_user["org_id"])
|
||||
org_name = org["name"] if org else None
|
||||
|
||||
await context.set_state(TicketStates.confirming)
|
||||
await context.set_state(TRANSITIONS["ticket:preview"])
|
||||
|
||||
mid = await bot_send(
|
||||
event.bot,
|
||||
|
|
@ -327,14 +321,14 @@ async def cb_done_ticket(
|
|||
# ── Шаг 5б: "Продолжить редактирование" ──────────────────────────────────────
|
||||
|
||||
@router.message_callback(
|
||||
F.callback.payload == "edit_ticket",
|
||||
states=TicketStates.confirming,
|
||||
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(TicketStates.collecting_attachments)
|
||||
await context.set_state(TRANSITIONS["ticket:edit"])
|
||||
|
||||
chat_id = event.message.recipient.chat_id if event.message else None
|
||||
if chat_id is None:
|
||||
|
|
@ -344,10 +338,7 @@ async def cb_edit_ticket(
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
text=(
|
||||
"✏️ Продолжайте добавлять описание или вложения.\n"
|
||||
"Нажмите «👁 Перейти к предпросмотру» когда будете готовы."
|
||||
),
|
||||
text=TicketTexts.CONTINUE_EDITING,
|
||||
attachments=[done_ticket_kb()],
|
||||
)
|
||||
await _track_mid(context, mid)
|
||||
|
|
@ -356,42 +347,63 @@ async def cb_edit_ticket(
|
|||
# ── Шаг 5в: "Отменить создание" ──────────────────────────────────────────────
|
||||
|
||||
@router.message_callback(
|
||||
F.callback.payload == "cancel_ticket",
|
||||
F.callback.payload == PAYLOADS.CANCEL_TICKET,
|
||||
states=[
|
||||
TicketStates.waiting_description,
|
||||
TicketStates.collecting_attachments,
|
||||
TicketStates.confirming,
|
||||
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="Создание отменено")
|
||||
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()
|
||||
|
||||
chat_id = event.message.recipient.chat_id if event.message else None
|
||||
if chat_id is None:
|
||||
return
|
||||
|
||||
menu_mid = await bot_send(
|
||||
loading_mid = await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
text=await build_main_menu_text(bot_user),
|
||||
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()],
|
||||
)
|
||||
await nav_set(context, menu_mid)
|
||||
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 == "confirm_ticket",
|
||||
states=TicketStates.confirming,
|
||||
F.callback.payload == PAYLOADS.CONFIRM_TICKET,
|
||||
states=TRANSITIONS["ticket:preview"],
|
||||
)
|
||||
async def cb_confirm_ticket(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
|
|
@ -411,7 +423,12 @@ async def cb_confirm_ticket(
|
|||
if chat_id is None:
|
||||
return
|
||||
|
||||
await event.message.answer("⏳ Отправляем заявку…")
|
||||
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:
|
||||
|
|
@ -477,29 +494,36 @@ async def cb_confirm_ticket(
|
|||
invalidate_client_cache(intradesk_id)
|
||||
|
||||
# ── Итоговое сообщение ────────────────────────────────────────────────────
|
||||
lines = ["✅ Заявка успешно создана!"]
|
||||
lines = [TicketTexts.CREATED]
|
||||
if intradesk_number:
|
||||
lines.append(f"Номер заявки: #{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 += ["", "📎 Вложения:"]
|
||||
lines += ["", TicketTexts.PREVIEW_ATTACHMENTS_HEADER]
|
||||
for att in pending_atts:
|
||||
lines.append(f" • {_att_label(att)}")
|
||||
lines += ["", "Наши специалисты свяжутся с вами в ближайшее время."]
|
||||
lines += ["", TicketTexts.CREATED_CONTACT]
|
||||
|
||||
await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
text="\n".join(lines),
|
||||
)
|
||||
|
||||
menu_mid = await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
text=await build_main_menu_text(bot_user),
|
||||
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()],
|
||||
)
|
||||
await nav_set(context, menu_mid)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ from maxapi.dispatcher import Router
|
|||
from maxapi.filters import F
|
||||
from maxapi.types.updates.message_callback import MessageCallback
|
||||
|
||||
from bot.flow_config import PAYLOADS, TRANSITIONS
|
||||
from bot.keyboards import main_menu_kb
|
||||
from bot.nav_helper import nav_finish, nav_prepare
|
||||
from bot.texts import MenuTexts
|
||||
from database.queries.organizations import get_organization
|
||||
from external.intradesk_api import count_client_open_tasks, get_client_last_comment
|
||||
|
||||
|
|
@ -26,8 +28,8 @@ async def build_main_menu_text(bot_user: dict) -> str:
|
|||
intradesk_id = org.get("intradesk_id") if org else None
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(f"🏢 {org_name}" if org_name else "🏠 Поддержка")
|
||||
lines.append("━" * 16)
|
||||
lines.append(MenuTexts.ORG_HEADER.format(org_name=org_name) if org_name else MenuTexts.NO_ORG_HEADER)
|
||||
lines.append(MenuTexts.SEPARATOR)
|
||||
|
||||
if intradesk_id:
|
||||
# Счётчик активных и последний комментарий — параллельно
|
||||
|
|
@ -38,13 +40,12 @@ async def build_main_menu_text(bot_user: dict) -> str:
|
|||
|
||||
if active == 0:
|
||||
lines += [
|
||||
"📭 Активных заявок нет",
|
||||
MenuTexts.NO_TICKETS,
|
||||
"",
|
||||
"Нажмите «Создать заявку» и мы",
|
||||
"свяжемся с вами в ближайшее время.",
|
||||
MenuTexts.NO_TICKETS_HINT,
|
||||
]
|
||||
else:
|
||||
lines.append(f"📋 Активных заявок: {active}")
|
||||
lines.append(MenuTexts.ACTIVE_COUNT.format(active=active))
|
||||
|
||||
if last_comment and last_comment.get("text"):
|
||||
ctext = last_comment["text"]
|
||||
|
|
@ -63,26 +64,26 @@ async def build_main_menu_text(bot_user: dict) -> str:
|
|||
num_str = f" (заявка #{task_num})" if task_num else ""
|
||||
lines += [
|
||||
"",
|
||||
f"💬 Последний ответ{num_str}:",
|
||||
MenuTexts.LAST_COMMENT.format(num_str=num_str),
|
||||
f"«{ctext}»",
|
||||
f"— {author}, {short_date}",
|
||||
MenuTexts.LAST_COMMENT_AUTHOR.format(author=author, short_date=short_date),
|
||||
]
|
||||
else:
|
||||
lines += ["", "💬 Новых ответов нет"]
|
||||
lines += ["", MenuTexts.NO_NEW_COMMENTS]
|
||||
else:
|
||||
lines.append("📭 Нет данных об организации")
|
||||
lines.append(MenuTexts.NO_ORG_DATA)
|
||||
|
||||
lines += ["", "━" * 16, "Выберите действие 👇"]
|
||||
lines += ["", MenuTexts.SEPARATOR, MenuTexts.CHOOSE_ACTION]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.message_callback(F.callback.payload == "main_menu")
|
||||
@router.message_callback(F.callback.payload == PAYLOADS.MAIN_MENU)
|
||||
async def cb_main_menu(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
) -> 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(TRANSITIONS["nav:menu"])
|
||||
await event.answer(notification="")
|
||||
|
||||
chat_id = event.message.recipient.chat_id if event.message else None
|
||||
|
|
@ -90,7 +91,7 @@ async def cb_main_menu(
|
|||
# Показываем «⏳» — edit-in-place если это последнее навигационное сообщение
|
||||
mid = await nav_prepare(
|
||||
event, context,
|
||||
loading_text="⏳ Загружаем меню...",
|
||||
loading_text=MenuTexts.LOADING,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
)
|
||||
|
|
|
|||
187
bot/handlers/quick_reply.py
Normal file
187
bot/handlers/quick_reply.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""
|
||||
Обработчик быстрого ответа на заявку.
|
||||
|
||||
Перехватывает свободные сообщения авторизованных пользователей вне активного
|
||||
флоу и предлагает отправить их как комментарий к последней активной заявке.
|
||||
Последняя активная заявка определяется по:
|
||||
- уведомлению, которое было отправлено пользователю (notifier)
|
||||
- карточке заявки, которую пользователь открывал (cb_iticket_detail)
|
||||
TTL контекста — 60 минут (настраивается в reply_context.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from maxapi.context import BaseContext
|
||||
from maxapi.dispatcher import Router
|
||||
from maxapi.filters import F
|
||||
from maxapi.types.updates.message_callback import MessageCallback
|
||||
from maxapi.types.updates.message_created import MessageCreated
|
||||
|
||||
from bot.filters import PayloadStartsWith
|
||||
from bot.flow_config import PAYLOADS, TRANSITIONS
|
||||
from bot.handlers.create_ticket import _extract_attachments
|
||||
from bot.keyboards import main_menu_kb, reply_confirm_kb
|
||||
from bot.nav_helper import nav_set
|
||||
from bot.send_helper import bot_send
|
||||
from bot.states import AuthStates, CommentStates, TicketStates
|
||||
from bot.texts import QuickReplyTexts
|
||||
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.reply_context import clear_reply_context, get_reply_context
|
||||
from external.intradesk_api import (
|
||||
add_task_comment,
|
||||
invalidate_client_cache,
|
||||
invalidate_task_comment,
|
||||
upload_attachments,
|
||||
)
|
||||
|
||||
router = Router(router_id="quick_reply")
|
||||
|
||||
_ACTIVE_STATES = [
|
||||
AuthStates.waiting_contact,
|
||||
TicketStates.waiting_description,
|
||||
TicketStates.collecting_attachments,
|
||||
TicketStates.confirming,
|
||||
TicketStates.pending_confirm,
|
||||
CommentStates.waiting_text,
|
||||
]
|
||||
|
||||
|
||||
@router.message_created()
|
||||
async def on_idle_message(
|
||||
event: MessageCreated, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
"""Перехватить свободное сообщение вне активного флоу."""
|
||||
state = await context.get_state()
|
||||
if state in _ACTIVE_STATES:
|
||||
return
|
||||
|
||||
chat_id, user_id = event.get_ids()
|
||||
if chat_id is None or user_id is None:
|
||||
return
|
||||
|
||||
body = event.message.body
|
||||
text = (body.text or "").strip() if body else ""
|
||||
file_atts = _extract_attachments(event)
|
||||
|
||||
if not text and not file_atts:
|
||||
return # стикер, локация и т.п. — игнорируем
|
||||
|
||||
ctx = await get_reply_context(user_id)
|
||||
if not ctx:
|
||||
return # нет активного контекста заявки — молча игнорируем
|
||||
|
||||
task_id: int = ctx["task_id"]
|
||||
task_number: int | None = ctx["task_number"]
|
||||
|
||||
await context.set_state(TRANSITIONS["reply:pending"])
|
||||
await context.set_data({
|
||||
"pending_reply_text": text,
|
||||
"pending_reply_atts": file_atts,
|
||||
"pending_reply_task_id": task_id,
|
||||
"pending_reply_task_number": task_number,
|
||||
})
|
||||
|
||||
display = text[:300] + ("…" if len(text) > 300 else "") if text else ""
|
||||
if file_atts:
|
||||
atts_note = f"\n📎 +{len(file_atts)} файл(ов)"
|
||||
display = (display + atts_note) if display else atts_note.strip()
|
||||
|
||||
await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text=QuickReplyTexts.CONFIRM.format(
|
||||
number=task_number or task_id,
|
||||
text=display,
|
||||
),
|
||||
attachments=[reply_confirm_kb(task_id)],
|
||||
)
|
||||
|
||||
|
||||
@router.message_callback(PayloadStartsWith(PAYLOADS.PFX_CONFIRM_REPLY))
|
||||
async def cb_confirm_reply(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
state = await context.get_state()
|
||||
if state != TRANSITIONS["reply:pending"]:
|
||||
await event.answer(notification="")
|
||||
return
|
||||
|
||||
await event.answer(notification="")
|
||||
|
||||
data = await context.get_data()
|
||||
task_id: int | None = data.get("pending_reply_task_id")
|
||||
task_number: int | None = data.get("pending_reply_task_number")
|
||||
text: str = data.get("pending_reply_text") or ""
|
||||
file_atts: list[dict] = data.get("pending_reply_atts") or []
|
||||
|
||||
await context.clear()
|
||||
await clear_reply_context(bot_user["max_user_id"])
|
||||
|
||||
chat_id = event.message.recipient.chat_id if event.message else None
|
||||
if not chat_id or not task_id:
|
||||
return
|
||||
|
||||
# Данные организации для contact_person_id и инвалидации кэша
|
||||
intradesk_id: int | None = None
|
||||
contact_person_id: int | None = None
|
||||
if bot_user.get("org_id"):
|
||||
org = await get_organization(bot_user["org_id"])
|
||||
intradesk_id = org.get("intradesk_id") if org else 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"]
|
||||
)
|
||||
|
||||
if file_atts:
|
||||
file_atts = await upload_attachments(file_atts, task_id=task_id, target="Comment")
|
||||
|
||||
ok = await add_task_comment(
|
||||
task_number=task_number,
|
||||
text=text or "📎 Файл прикреплён",
|
||||
contact_person_id=contact_person_id,
|
||||
attachments=file_atts or None,
|
||||
)
|
||||
|
||||
if ok:
|
||||
invalidate_task_comment(task_id)
|
||||
if intradesk_id:
|
||||
invalidate_client_cache(intradesk_id)
|
||||
|
||||
result = (
|
||||
QuickReplyTexts.SENT.format(number=task_number or task_id)
|
||||
if ok else QuickReplyTexts.ERROR
|
||||
)
|
||||
mid = await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
text=result,
|
||||
attachments=[main_menu_kb()],
|
||||
)
|
||||
await nav_set(context, mid)
|
||||
|
||||
|
||||
@router.message_callback(F.callback.payload == PAYLOADS.CANCEL_REPLY)
|
||||
async def cb_cancel_reply(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
await event.answer(notification="")
|
||||
await context.clear()
|
||||
|
||||
chat_id = event.message.recipient.chat_id if event.message else None
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
from bot.handlers.menu import build_main_menu_text
|
||||
|
||||
mid = 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()],
|
||||
)
|
||||
await nav_set(context, mid)
|
||||
|
|
@ -16,11 +16,12 @@ from maxapi.types.updates.message_created import MessageCreated
|
|||
from maxapi.context import BaseContext
|
||||
from maxapi.filters.command import Command
|
||||
|
||||
from bot.flow_config import TRANSITIONS
|
||||
from bot.keyboards import contact_request_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.states import AuthStates
|
||||
from bot.texts import AuthTexts
|
||||
|
||||
logger = get_logger("bot.handlers.start")
|
||||
from database.queries.bot_users import (
|
||||
|
|
@ -59,18 +60,15 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
|
|||
event.bot,
|
||||
chat_id=event.chat_id,
|
||||
user_id=user.user_id,
|
||||
text=(
|
||||
"⛔ Ваш номер не найден в системе. Доступ заблокирован.\n"
|
||||
"Обратитесь к администратору для подключения."
|
||||
),
|
||||
text=AuthTexts.BLOCKED,
|
||||
)
|
||||
return
|
||||
|
||||
if bot_user["is_authorized"]:
|
||||
logger.info("✅ bot_started returning user | user_id=%s", user.user_id)
|
||||
await context.set_state(None)
|
||||
await context.set_state(TRANSITIONS["auth:authorized"])
|
||||
from bot.handlers.menu import build_main_menu_text
|
||||
menu_text = "👋 С возвращением!\n\n" + await build_main_menu_text(bot_user)
|
||||
menu_text = AuthTexts.WELCOME_BACK + await build_main_menu_text(bot_user)
|
||||
mid = await bot_send(
|
||||
event.bot,
|
||||
chat_id=event.chat_id,
|
||||
|
|
@ -83,15 +81,12 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
|
|||
|
||||
# Новый пользователь — запрашиваем контакт
|
||||
logger.info("👋 bot_started new user | user_id=%s", user.user_id)
|
||||
await context.set_state(AuthStates.waiting_contact)
|
||||
await context.set_state(TRANSITIONS["auth:new_user"])
|
||||
await bot_send(
|
||||
event.bot,
|
||||
chat_id=event.chat_id,
|
||||
user_id=user.user_id,
|
||||
text=(
|
||||
"👋 Добро пожаловать в систему технической поддержки!\n\n"
|
||||
"Для входа нажмите кнопку ниже и поделитесь своим номером телефона."
|
||||
),
|
||||
text=AuthTexts.WELCOME_NEW,
|
||||
attachments=[contact_request_kb()],
|
||||
)
|
||||
|
||||
|
|
@ -112,15 +107,12 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
|
|||
username=None,
|
||||
first_name=None,
|
||||
)
|
||||
await context.set_state(AuthStates.waiting_contact)
|
||||
await context.set_state(TRANSITIONS["auth:new_user"])
|
||||
await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text=(
|
||||
"👋 Добро пожаловать!\n\n"
|
||||
"Поделитесь номером телефона для доступа к системе."
|
||||
),
|
||||
text=AuthTexts.WELCOME_START,
|
||||
attachments=[contact_request_kb()],
|
||||
)
|
||||
return
|
||||
|
|
@ -130,22 +122,22 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text="⛔ Доступ заблокирован. Обратитесь к администратору.",
|
||||
text=AuthTexts.BLOCKED_SHORT,
|
||||
)
|
||||
return
|
||||
|
||||
if not bot_user["is_authorized"]:
|
||||
await context.set_state(AuthStates.waiting_contact)
|
||||
await context.set_state(TRANSITIONS["auth:new_user"])
|
||||
await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text="Поделитесь номером телефона для доступа:",
|
||||
text=AuthTexts.SHARE_PHONE,
|
||||
attachments=[contact_request_kb()],
|
||||
)
|
||||
return
|
||||
|
||||
await context.set_state(None)
|
||||
await context.set_state(TRANSITIONS["auth:authorized"])
|
||||
from bot.handlers.menu import build_main_menu_text
|
||||
menu_text = await build_main_menu_text(bot_user)
|
||||
mid = await bot_send(
|
||||
|
|
@ -158,7 +150,7 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
|
|||
await nav_set(context, mid)
|
||||
|
||||
|
||||
@router.message_created(ContactFilter(), states=AuthStates.waiting_contact)
|
||||
@router.message_created(ContactFilter(), states=TRANSITIONS["auth:new_user"])
|
||||
async def on_contact_shared(
|
||||
event: MessageCreated,
|
||||
contact: ContactAttachment,
|
||||
|
|
@ -175,7 +167,7 @@ async def on_contact_shared(
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text="Не удалось прочитать контакт. Попробуйте ещё раз.",
|
||||
text=AuthTexts.CONTACT_READ_ERROR,
|
||||
attachments=[contact_request_kb()],
|
||||
)
|
||||
return
|
||||
|
|
@ -187,7 +179,7 @@ async def on_contact_shared(
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text="В контакте не найден номер телефона. Попробуйте ещё раз.",
|
||||
text=AuthTexts.CONTACT_NO_PHONE,
|
||||
attachments=[contact_request_kb()],
|
||||
)
|
||||
return
|
||||
|
|
@ -209,18 +201,23 @@ async def on_contact_shared(
|
|||
if org_user is None:
|
||||
logger.warning("🚫 phone not found, blocking | user_id=%s phone=%s", user_id, phone)
|
||||
await block_bot_user(max_user_id=user_id, phone=phone)
|
||||
await context.set_state(None)
|
||||
await context.set_state(TRANSITIONS["auth:blocked"])
|
||||
await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text=(
|
||||
"⛔ Ваш номер не найден в системе.\n"
|
||||
"Доступ заблокирован. Обратитесь к администратору для подключения."
|
||||
),
|
||||
text=AuthTexts.PHONE_NOT_FOUND,
|
||||
)
|
||||
return
|
||||
|
||||
# Мгновенно показываем заглушку, пока линкуем аккаунт
|
||||
mid = await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text=AuthTexts.LOADING,
|
||||
)
|
||||
|
||||
# Авторизуем
|
||||
logger.info("✅ authorized | user_id=%s phone=%s org_id=%s", user_id, phone, org_user["org_id"])
|
||||
await authorize_bot_user(
|
||||
|
|
@ -229,17 +226,16 @@ async def on_contact_shared(
|
|||
org_id=org_user["org_id"],
|
||||
first_name=display_name,
|
||||
)
|
||||
await context.set_state(None)
|
||||
await context.set_state(TRANSITIONS["auth:contact_found"])
|
||||
|
||||
bot_user = await get_bot_user_by_max_id(user_id)
|
||||
|
||||
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)
|
||||
menu_text = AuthTexts.AUTHORIZED + await build_main_menu_text(bot_user)
|
||||
|
||||
mid = await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
# Заменяем заглушку финальным меню
|
||||
await event.bot.edit_message(
|
||||
message_id=mid,
|
||||
text=menu_text,
|
||||
attachments=[main_menu_kb()],
|
||||
)
|
||||
|
|
@ -261,7 +257,7 @@ async def on_menu_command(event: MessageCreated, context: BaseContext) -> 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(TRANSITIONS["nav:menu"])
|
||||
|
||||
from bot.handlers.menu import build_main_menu_text
|
||||
mid = await bot_send(
|
||||
|
|
@ -289,6 +285,7 @@ async def on_cancel_command(event: MessageCreated, context: BaseContext) -> None
|
|||
|
||||
from bot.handlers.create_ticket import cleanup_ticket_flow
|
||||
from bot.states import CommentStates, TicketStates
|
||||
from bot.texts import MenuTexts
|
||||
|
||||
state = await context.get_state()
|
||||
active_states = [
|
||||
|
|
@ -300,11 +297,11 @@ async def on_cancel_command(event: MessageCreated, context: BaseContext) -> None
|
|||
|
||||
if state in active_states:
|
||||
await cleanup_ticket_flow(event.bot, context)
|
||||
cancel_text = "❌ Действие отменено.\n\n"
|
||||
cancel_text = MenuTexts.CANCEL_PREFIX
|
||||
else:
|
||||
cancel_text = ""
|
||||
|
||||
await context.set_state(None)
|
||||
await context.set_state(TRANSITIONS["nav:cancel"])
|
||||
|
||||
from bot.handlers.menu import build_main_menu_text
|
||||
mid = await bot_send(
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@ from maxapi.types.updates.message_callback import MessageCallback
|
|||
|
||||
import config
|
||||
from bot.filters import PayloadStartsWith
|
||||
from bot.flow_config import PAYLOADS, TRANSITIONS
|
||||
from bot.handlers.create_ticket import _extract_attachments
|
||||
from bot.keyboards import comment_input_kb, iticket_detail_kb, main_menu_kb, menu_only_kb
|
||||
from bot.nav_helper import nav_finish, nav_prepare, nav_set
|
||||
from bot.send_helper import bot_send
|
||||
from bot.states import CommentStates
|
||||
from bot.texts import CommentTexts, TicketDetailTexts, TicketListTexts
|
||||
from database.connection import get_db
|
||||
from database.queries.intradesk import get_intradesk_contact_id_for_phone
|
||||
from database.queries.organizations import get_organization
|
||||
|
|
@ -76,7 +77,7 @@ def _fmt_date_short(iso: str) -> str:
|
|||
|
||||
def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
|
||||
pages = max(1, -(-total // _PAGE_SIZE))
|
||||
lines = [f"📋 Активные заявки (всего: {total}) — стр. {page + 1}/{pages}"]
|
||||
lines = [TicketListTexts.HEADER.format(total=total, page=page + 1, pages=pages)]
|
||||
|
||||
for t in tasks:
|
||||
icon = _status_icon(t.get("status_name") or "")
|
||||
|
|
@ -100,7 +101,7 @@ def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
|
|||
cdate = _fmt_date_short(comment.get("date") or "")
|
||||
lines.append(f'💬 "{ctext}" — {author_short} ({cdate})')
|
||||
else:
|
||||
lines.append("💬 Нет комментариев")
|
||||
lines.append(TicketListTexts.NO_COMMENTS)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
|
@ -144,7 +145,7 @@ async def _load_and_show(
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
text="⏳ Загружаем список заявок...",
|
||||
text=TicketListTexts.LOADING,
|
||||
attachments=[],
|
||||
)
|
||||
|
||||
|
|
@ -159,7 +160,7 @@ async def _load_and_show(
|
|||
await _delete_msg(event.bot, nav_mid)
|
||||
return
|
||||
await _edit_or_send(event.bot, nav_mid, chat_id,
|
||||
text="⚠️ Ваша организация не привязана к Intradesk. Обратитесь к администратору.",
|
||||
text=TicketListTexts.NO_ORG,
|
||||
attachments=[menu_only_kb()])
|
||||
return
|
||||
|
||||
|
|
@ -180,7 +181,7 @@ async def _load_and_show(
|
|||
|
||||
if not tasks:
|
||||
await _edit_or_send(event.bot, nav_mid, chat_id,
|
||||
text="📭 Активных заявок нет.",
|
||||
text=TicketListTexts.EMPTY,
|
||||
attachments=[menu_only_kb()])
|
||||
return
|
||||
|
||||
|
|
@ -206,7 +207,7 @@ async def _edit_or_send(bot, mid: str | None, chat_id: int | None, *, text: str,
|
|||
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 == PAYLOADS.VIEW_TICKETS)
|
||||
async def cb_view_tickets(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
|
|
@ -217,14 +218,14 @@ async def cb_view_tickets(
|
|||
chat_id = event.message.recipient.chat_id if event.message else None
|
||||
nav_mid = await nav_prepare(
|
||||
event, context,
|
||||
loading_text="⏳ Загружаем список заявок...",
|
||||
loading_text=TicketListTexts.LOADING,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
)
|
||||
await _load_and_show(event, bot_user, page=0, context=context, nav_mid=nav_mid)
|
||||
|
||||
|
||||
@router.message_callback(PayloadStartsWith("itickets_page:"))
|
||||
@router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKETS_PAGE))
|
||||
async def cb_itickets_page(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
|
|
@ -237,21 +238,21 @@ async def cb_itickets_page(
|
|||
chat_id = event.message.recipient.chat_id if event.message else None
|
||||
nav_mid = await nav_prepare(
|
||||
event, context,
|
||||
loading_text="⏳ Загружаем список заявок...",
|
||||
loading_text=TicketListTexts.LOADING,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
)
|
||||
await _load_and_show(event, bot_user, page=page, context=context, nav_mid=nav_mid)
|
||||
|
||||
|
||||
@router.message_callback(PayloadStartsWith("iticket_detail:"))
|
||||
@router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKET_DETAIL))
|
||||
async def cb_iticket_detail(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
try:
|
||||
task_id = int(event.callback.payload.split(":")[1])
|
||||
except (IndexError, ValueError):
|
||||
await event.answer(notification="Ошибка")
|
||||
await event.answer(notification=TicketDetailTexts.GENERIC_ERROR)
|
||||
return
|
||||
|
||||
await event.answer(notification="")
|
||||
|
|
@ -260,11 +261,11 @@ async def cb_iticket_detail(
|
|||
# Если пришли сюда из комментарийного флоу (кнопка «Назад») —
|
||||
# удаляем промпт-сообщение и сбрасываем state, сохраняя кэш задач.
|
||||
state = await context.get_state()
|
||||
if state == CommentStates.waiting_text:
|
||||
if state == TRANSITIONS["comment:start"]:
|
||||
d = await context.get_data()
|
||||
for mid in (d.get("flow_message_ids") or []):
|
||||
await _delete_msg(event.bot, mid)
|
||||
await context.set_state(None)
|
||||
await context.set_state(TRANSITIONS["comment:done"])
|
||||
# Сохраняем кэш задач, но сбрасываем nav_mid (промпт уже удалён)
|
||||
await context.set_data({
|
||||
"tasks": d.get("tasks"),
|
||||
|
|
@ -275,7 +276,7 @@ async def cb_iticket_detail(
|
|||
# Показываем ⏳ (edit-in-place если меню/список последнее, иначе новое)
|
||||
nav_mid = await nav_prepare(
|
||||
event, context,
|
||||
loading_text="⏳ Загружаем заявку...",
|
||||
loading_text=TicketDetailTexts.LOADING,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
)
|
||||
|
|
@ -287,7 +288,7 @@ async def cb_iticket_detail(
|
|||
if task is None:
|
||||
await nav_finish(
|
||||
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
||||
text="⚠️ Заявка не найдена. Вернитесь к списку.",
|
||||
text=TicketDetailTexts.NOT_FOUND,
|
||||
attachments=[iticket_detail_kb()],
|
||||
)
|
||||
return
|
||||
|
|
@ -308,12 +309,12 @@ async def cb_iticket_detail(
|
|||
desc = task["description"]
|
||||
if len(desc) > 600:
|
||||
desc = desc[:600] + "…"
|
||||
lines += ["", "📝 Описание:", desc]
|
||||
lines += ["", TicketDetailTexts.DESCRIPTION_HEADER, desc]
|
||||
|
||||
# Последние 10 комментариев из history API
|
||||
comments = await get_task_comments(task["id"], count=10)
|
||||
if comments:
|
||||
lines += ["", f"💬 Комментарии ({len(comments)}):"]
|
||||
lines += ["", TicketDetailTexts.COMMENTS_HEADER.format(count=len(comments))]
|
||||
for c in comments:
|
||||
ctext = c["text"]
|
||||
if len(ctext) > 300:
|
||||
|
|
@ -325,7 +326,7 @@ async def cb_iticket_detail(
|
|||
ctext,
|
||||
]
|
||||
else:
|
||||
lines += ["", "💬 Комментариев нет"]
|
||||
lines += ["", TicketDetailTexts.NO_COMMENTS]
|
||||
|
||||
await nav_finish(
|
||||
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
||||
|
|
@ -333,17 +334,20 @@ async def cb_iticket_detail(
|
|||
attachments=[iticket_detail_kb(task_id=task["id"], can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
|
||||
)
|
||||
|
||||
from database.queries.reply_context import set_reply_context
|
||||
await set_reply_context(bot_user["max_user_id"], task_id, task.get("task_number"))
|
||||
|
||||
|
||||
# ── Закрыть заявку ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.message_callback(PayloadStartsWith("iticket_close:"))
|
||||
@router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKET_CLOSE))
|
||||
async def cb_close_ticket(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
try:
|
||||
task_id = int(event.callback.payload.split(":")[1])
|
||||
except (IndexError, ValueError):
|
||||
await event.answer(notification="Ошибка")
|
||||
await event.answer(notification=TicketDetailTexts.GENERIC_ERROR)
|
||||
return
|
||||
|
||||
await event.answer(notification="")
|
||||
|
|
@ -358,20 +362,20 @@ async def cb_close_ticket(
|
|||
if not task_number:
|
||||
nav_mid = await nav_prepare(
|
||||
event, context,
|
||||
loading_text="⏳ Закрываем заявку...",
|
||||
loading_text=TicketDetailTexts.CLOSING,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
)
|
||||
await nav_finish(
|
||||
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
||||
text="⚠️ Не удалось определить номер заявки. Вернитесь к списку.",
|
||||
text=TicketDetailTexts.CLOSE_NUMBER_ERROR,
|
||||
attachments=[iticket_detail_kb()],
|
||||
)
|
||||
return
|
||||
|
||||
nav_mid = await nav_prepare(
|
||||
event, context,
|
||||
loading_text="⏳ Закрываем заявку...",
|
||||
loading_text=TicketDetailTexts.CLOSING,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
)
|
||||
|
|
@ -398,27 +402,33 @@ async def cb_close_ticket(
|
|||
num_str = f" #{task_number}" if task_number else ""
|
||||
await nav_finish(
|
||||
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
||||
text=f"✅ Заявка{num_str} закрыта.",
|
||||
text=TicketDetailTexts.CLOSED.format(num_str=num_str),
|
||||
attachments=[menu_only_kb()],
|
||||
)
|
||||
else:
|
||||
await nav_finish(
|
||||
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
||||
text="⚠️ Не удалось закрыть заявку. Попробуйте позже.",
|
||||
text=TicketDetailTexts.CLOSE_ERROR,
|
||||
attachments=[iticket_detail_kb(task_id=task_id, can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
|
||||
)
|
||||
|
||||
|
||||
# ── Оставить комментарий ──────────────────────────────────────────────────────
|
||||
|
||||
@router.message_callback(PayloadStartsWith("iticket_comment:"))
|
||||
@router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKET_COMMENT))
|
||||
async def cb_add_comment(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
# Защита от спама: если уже в режиме ввода комментария — игнорируем повторный клик
|
||||
state = await context.get_state()
|
||||
if state == TRANSITIONS["comment:start"]:
|
||||
await event.answer(notification="")
|
||||
return
|
||||
|
||||
try:
|
||||
task_id = int(event.callback.payload.split(":")[1])
|
||||
except (IndexError, ValueError):
|
||||
await event.answer(notification="Ошибка")
|
||||
await event.answer(notification=TicketDetailTexts.GENERIC_ERROR)
|
||||
return
|
||||
|
||||
await event.answer(notification="")
|
||||
|
|
@ -428,7 +438,7 @@ async def cb_add_comment(
|
|||
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
|
||||
task_number = task.get("task_number") if task else None
|
||||
|
||||
await context.set_state(CommentStates.waiting_text)
|
||||
await context.set_state(TRANSITIONS["comment:start"])
|
||||
await context.set_data({
|
||||
"comment_task_id": task_id,
|
||||
"comment_task_number": task_number,
|
||||
|
|
@ -448,11 +458,7 @@ async def cb_add_comment(
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=bot_user["max_user_id"],
|
||||
text=(
|
||||
f"💬 Напишите комментарий{num_str}:\n\n"
|
||||
"Можно отправить текст, файл, фото или голосовое сообщение.\n"
|
||||
"Нажмите «← Назад к заявке» для отмены."
|
||||
),
|
||||
text=CommentTexts.PROMPT.format(num_str=num_str),
|
||||
attachments=[comment_input_kb(task_id)],
|
||||
)
|
||||
# Сохраняем mid для возможной очистки
|
||||
|
|
@ -462,9 +468,9 @@ async def cb_add_comment(
|
|||
await context.set_data(d)
|
||||
|
||||
|
||||
@router.message_created(states=CommentStates.waiting_text)
|
||||
@router.message_created(states=TRANSITIONS["comment:start"])
|
||||
async def on_comment_text(
|
||||
event: MessageCreated, context: BaseContext, bot_user: dict
|
||||
event, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
chat_id, user_id = event.get_ids()
|
||||
if chat_id is None:
|
||||
|
|
@ -479,7 +485,7 @@ async def on_comment_text(
|
|||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text="❗ Комментарий не может быть пустым. Напишите текст или прикрепите файл:",
|
||||
text=CommentTexts.EMPTY_ERROR,
|
||||
attachments=[menu_only_kb()],
|
||||
)
|
||||
return
|
||||
|
|
@ -509,7 +515,7 @@ async def on_comment_text(
|
|||
file_atts, task_id=task_id or 0, target="Comment"
|
||||
)
|
||||
|
||||
comment_text = text or "📎 Файл прикреплён"
|
||||
comment_text = text or CommentTexts.FILE_ONLY
|
||||
|
||||
ok = await add_task_comment(
|
||||
task_number=task_number,
|
||||
|
|
@ -525,29 +531,14 @@ async def on_comment_text(
|
|||
if intradesk_id:
|
||||
invalidate_client_cache(intradesk_id)
|
||||
|
||||
from bot.handlers.menu import build_main_menu_text
|
||||
|
||||
if ok:
|
||||
num_str = f" к заявке #{task_number}" if task_number else ""
|
||||
await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text=f"✅ Комментарий{num_str} успешно отправлен!",
|
||||
)
|
||||
else:
|
||||
await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text="⚠️ Не удалось отправить комментарий. Попробуйте позже.",
|
||||
)
|
||||
result_text = CommentTexts.SUCCESS.format(num_str=num_str) if ok else CommentTexts.ERROR
|
||||
|
||||
menu_mid = await bot_send(
|
||||
event.bot,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
text=await build_main_menu_text(bot_user),
|
||||
text=result_text,
|
||||
attachments=[main_menu_kb()],
|
||||
)
|
||||
await nav_set(context, menu_mid)
|
||||
|
|
|
|||
108
bot/keyboards.py
108
bot/keyboards.py
|
|
@ -5,12 +5,17 @@ from maxapi.types.attachments.buttons import (
|
|||
)
|
||||
from maxapi.utils.inline_keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot.flow_config import PAYLOADS
|
||||
from bot.texts import Btn
|
||||
|
||||
|
||||
def main_menu_kb() -> Attachment:
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
.row(CallbackButton(text="📝 Создать заявку", payload="create_ticket"))
|
||||
.row(CallbackButton(text="📋 Мои заявки", payload="view_tickets"))
|
||||
.row(
|
||||
CallbackButton(text=Btn.CREATE_TICKET, payload=PAYLOADS.CREATE_TICKET),
|
||||
CallbackButton(text=Btn.VIEW_TICKETS, payload=PAYLOADS.VIEW_TICKETS),
|
||||
)
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
|
|
@ -18,7 +23,7 @@ def main_menu_kb() -> Attachment:
|
|||
def contact_request_kb() -> Attachment:
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
.row(RequestContactButton(text="📱 Поделиться контактом"))
|
||||
.row(RequestContactButton(text=Btn.SHARE_CONTACT))
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
|
|
@ -27,9 +32,11 @@ 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"))
|
||||
.row(CallbackButton(text=Btn.CONFIRM_TICKET, payload=PAYLOADS.CONFIRM_TICKET))
|
||||
.row(
|
||||
CallbackButton(text=Btn.EDIT_TICKET, payload=PAYLOADS.EDIT_TICKET),
|
||||
CallbackButton(text=Btn.CANCEL_TICKET, payload=PAYLOADS.CANCEL_TICKET),
|
||||
)
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
|
|
@ -38,7 +45,7 @@ def menu_only_kb() -> Attachment:
|
|||
"""Клавиатура с единственной кнопкой возврата в главное меню."""
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||
.row(CallbackButton(text=Btn.MAIN_MENU, payload=PAYLOADS.MAIN_MENU))
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
|
|
@ -46,8 +53,10 @@ def menu_only_kb() -> Attachment:
|
|||
def done_ticket_kb() -> Attachment:
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
.row(CallbackButton(text="✅ Готово", payload="done_ticket"))
|
||||
.row(CallbackButton(text="❌ Отменить создание", payload="cancel_ticket"))
|
||||
.row(
|
||||
CallbackButton(text=Btn.DONE_TICKET, payload=PAYLOADS.DONE_TICKET),
|
||||
CallbackButton(text=Btn.CANCEL_TICKET, payload=PAYLOADS.CANCEL_TICKET),
|
||||
)
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
|
|
@ -66,19 +75,19 @@ def tickets_list_kb(
|
|||
builder.row(
|
||||
CallbackButton(
|
||||
text=f"#{t['id']} {status_icon} {desc_preview}",
|
||||
payload=f"ticket_detail:{t['id']}",
|
||||
payload=PAYLOADS.TICKET_DETAIL.format(id=t["id"]),
|
||||
)
|
||||
)
|
||||
|
||||
nav = []
|
||||
if page > 0:
|
||||
nav.append(CallbackButton(text="← Назад", payload=f"tickets_page:{page - 1}"))
|
||||
nav.append(CallbackButton(text=Btn.PREV_PAGE, payload=PAYLOADS.TICKETS_PAGE.format(page=page - 1)))
|
||||
if (page + 1) * page_size < total:
|
||||
nav.append(CallbackButton(text="Вперёд →", payload=f"tickets_page:{page + 1}"))
|
||||
nav.append(CallbackButton(text=Btn.NEXT_PAGE, payload=PAYLOADS.TICKETS_PAGE.format(page=page + 1)))
|
||||
if nav:
|
||||
builder.row(*nav)
|
||||
|
||||
builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||
builder.row(CallbackButton(text=Btn.MAIN_MENU, payload=PAYLOADS.MAIN_MENU))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
|
@ -103,37 +112,42 @@ def intradesk_tasks_list_kb(
|
|||
builder.row(
|
||||
CallbackButton(
|
||||
text=f"#{label} {icon} {name_preview}",
|
||||
payload=f"iticket_detail:{t['id']}",
|
||||
payload=PAYLOADS.ITICKET_DETAIL.format(id=t["id"]),
|
||||
)
|
||||
)
|
||||
|
||||
nav = []
|
||||
if page > 0:
|
||||
nav.append(CallbackButton(text="← Пред.", payload=f"itickets_page:{page - 1}"))
|
||||
nav.append(CallbackButton(text=Btn.PREV_PAGE, payload=PAYLOADS.ITICKETS_PAGE.format(page=page - 1)))
|
||||
if (page + 1) * page_size < total:
|
||||
nav.append(CallbackButton(text="След. →", payload=f"itickets_page:{page + 1}"))
|
||||
nav.append(CallbackButton(text=Btn.NEXT_PAGE, payload=PAYLOADS.ITICKETS_PAGE.format(page=page + 1)))
|
||||
if nav:
|
||||
builder.row(*nav)
|
||||
|
||||
builder.row(CallbackButton(text="← Назад в меню", payload="main_menu"))
|
||||
builder.row(CallbackButton(text=Btn.BACK_TO_MENU, payload=PAYLOADS.MAIN_MENU))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def iticket_detail_kb(task_id: int | None = None, can_close: bool = False) -> Attachment:
|
||||
"""Клавиатура внутри карточки Intradesk-задачи."""
|
||||
builder = InlineKeyboardBuilder()
|
||||
action_row = []
|
||||
if task_id is not None:
|
||||
builder.row(CallbackButton(
|
||||
text="💬 Оставить комментарий",
|
||||
payload=f"iticket_comment:{task_id}",
|
||||
action_row.append(CallbackButton(
|
||||
text=Btn.ADD_COMMENT,
|
||||
payload=PAYLOADS.ITICKET_COMMENT.format(task_id=task_id),
|
||||
))
|
||||
if task_id is not None and can_close:
|
||||
builder.row(CallbackButton(
|
||||
text="🔒 Закрыть заявку",
|
||||
payload=f"iticket_close:{task_id}",
|
||||
action_row.append(CallbackButton(
|
||||
text=Btn.CLOSE_TICKET,
|
||||
payload=PAYLOADS.ITICKET_CLOSE.format(task_id=task_id),
|
||||
))
|
||||
builder.row(CallbackButton(text="← К списку заявок", payload="itickets_page:0"))
|
||||
builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||
if action_row:
|
||||
builder.row(*action_row)
|
||||
builder.row(
|
||||
CallbackButton(text=Btn.BACK_TO_LIST, payload=PAYLOADS.ITICKETS_PAGE.format(page=0)),
|
||||
CallbackButton(text=Btn.MAIN_MENU, payload=PAYLOADS.MAIN_MENU),
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
|
@ -141,18 +155,48 @@ def comment_input_kb(task_id: int) -> Attachment:
|
|||
"""Клавиатура при вводе комментария — кнопка назад и меню."""
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
.row(CallbackButton(text="← Назад к заявке", payload=f"iticket_detail:{task_id}"))
|
||||
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||
.row(
|
||||
CallbackButton(
|
||||
text=Btn.BACK_TO_TICKET,
|
||||
payload=PAYLOADS.ITICKET_DETAIL.format(id=task_id),
|
||||
),
|
||||
CallbackButton(text=Btn.MAIN_MENU, payload=PAYLOADS.MAIN_MENU),
|
||||
)
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
|
||||
def notification_kb() -> Attachment:
|
||||
def notification_kb(task_id: int, task_number: int | None = None) -> Attachment:
|
||||
"""Клавиатура под уведомлением о новом комментарии."""
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
.row(CallbackButton(text="📋 Мои заявки", payload="view_tickets"))
|
||||
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||
.row(
|
||||
CallbackButton(
|
||||
text=Btn.REPLY_TO_TICKET,
|
||||
payload=PAYLOADS.ITICKET_COMMENT.format(task_id=task_id),
|
||||
),
|
||||
CallbackButton(
|
||||
text=Btn.OPEN_TICKET,
|
||||
payload=PAYLOADS.ITICKET_DETAIL.format(id=task_id),
|
||||
),
|
||||
)
|
||||
.row(CallbackButton(text=Btn.MAIN_MENU, payload=PAYLOADS.MAIN_MENU))
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
|
||||
def reply_confirm_kb(task_id: int) -> Attachment:
|
||||
"""Клавиатура подтверждения быстрого ответа на заявку."""
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
.row(
|
||||
CallbackButton(
|
||||
text=Btn.CONFIRM_REPLY,
|
||||
payload=PAYLOADS.CONFIRM_REPLY.format(task_id=task_id),
|
||||
),
|
||||
CallbackButton(text=Btn.CANCEL_REPLY, payload=PAYLOADS.CANCEL_REPLY),
|
||||
)
|
||||
.row(CallbackButton(text=Btn.CHOOSE_TICKET, payload=PAYLOADS.VIEW_TICKETS))
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
|
|
@ -160,7 +204,7 @@ def notification_kb() -> Attachment:
|
|||
def ticket_detail_kb(page: int = 0) -> Attachment:
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
.row(CallbackButton(text="← К списку", payload=f"tickets_page:{page}"))
|
||||
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||
.row(CallbackButton(text=Btn.BACK_TO_TICKETS, payload=PAYLOADS.TICKETS_PAGE.format(page=page)))
|
||||
.row(CallbackButton(text=Btn.MAIN_MENU, payload=PAYLOADS.MAIN_MENU))
|
||||
.as_markup()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -35,8 +35,16 @@ from external.intradesk_api import (
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _send_notification(bot, chat_id: int, task_number: int, comment: dict) -> None:
|
||||
async def _send_notification(
|
||||
bot,
|
||||
chat_id: int,
|
||||
max_user_id: int,
|
||||
task_id: int,
|
||||
task_number: int,
|
||||
comment: dict,
|
||||
) -> None:
|
||||
from bot.keyboards import notification_kb
|
||||
from database.queries.reply_context import set_reply_context
|
||||
|
||||
ctext = comment["text"]
|
||||
if len(ctext) > 200:
|
||||
|
|
@ -44,14 +52,16 @@ async def _send_notification(bot, chat_id: int, task_number: int, comment: dict)
|
|||
|
||||
text = (
|
||||
f"💬 Новый ответ по заявке #{task_number}\n\n"
|
||||
f"«{ctext}»\n"
|
||||
f"— {comment['author']}, {comment['date']}"
|
||||
f"«{ctext}»\n"
|
||||
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
attachments=[notification_kb()],
|
||||
attachments=[notification_kb(task_id=task_id, task_number=task_number)],
|
||||
)
|
||||
await set_reply_context(max_user_id, task_id, task_number)
|
||||
|
||||
|
||||
async def _process_task(task: dict, org_id: int, bot) -> None:
|
||||
|
|
@ -92,15 +102,16 @@ async def _process_task(task: dict, org_id: int, bot) -> None:
|
|||
notified = 0
|
||||
for user in users:
|
||||
chat_id = user.get("max_chat_id")
|
||||
if not chat_id:
|
||||
max_user_id = user.get("max_user_id")
|
||||
if not chat_id or not max_user_id:
|
||||
continue
|
||||
try:
|
||||
await _send_notification(bot, chat_id, task_number, latest)
|
||||
await _send_notification(bot, chat_id, max_user_id, tid, task_number, latest)
|
||||
notified += 1
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"notify failed user=%s task=%s: %s",
|
||||
user.get("max_user_id"), task_number, exc,
|
||||
max_user_id, task_number, exc,
|
||||
)
|
||||
logger.info(
|
||||
"notifier: task=%s (#%s) new comment → notified %d users",
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ from maxapi.dispatcher import Dispatcher, Router
|
|||
import config
|
||||
from bot.bot_instance import set_bot
|
||||
from bot.middleware import AuthMiddleware
|
||||
from bot.handlers import start, menu, create_ticket, view_tickets
|
||||
from bot.handlers import start, menu, create_ticket, view_tickets, quick_reply
|
||||
|
||||
|
||||
def create_bot_and_dispatcher() -> tuple[Bot, Dispatcher]:
|
||||
bot = Bot(token=config.BOT_TOKEN)
|
||||
bot.headers["Accept-Encoding"] = "gzip, deflate"
|
||||
set_bot(bot)
|
||||
|
||||
dp = Dispatcher(router_id="main", storage=MemoryContext)
|
||||
|
|
@ -21,6 +22,7 @@ def create_bot_and_dispatcher() -> tuple[Bot, Dispatcher]:
|
|||
menu.router,
|
||||
create_ticket.router,
|
||||
view_tickets.router,
|
||||
quick_reply.router, # последним — catch-all для свободных сообщений
|
||||
)
|
||||
|
||||
return bot, dp
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class TicketStates(StatesGroup):
|
|||
waiting_description = State() # Ожидаем первое сообщение с описанием
|
||||
collecting_attachments = State() # Накапливаем доп. файлы/текст
|
||||
confirming = State() # Предпросмотр — ждём подтверждения
|
||||
pending_confirm = State() # Быстрый ответ — ждём подтверждения отправки
|
||||
|
||||
|
||||
class CommentStates(StatesGroup):
|
||||
|
|
|
|||
193
bot/texts.py
Normal file
193
bot/texts.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""
|
||||
Все пользовательские тексты и подписи кнопок.
|
||||
|
||||
Шаблоны с плейсхолдерами форматируются на месте вызова через .format(...).
|
||||
"""
|
||||
|
||||
|
||||
class AuthTexts:
|
||||
# bot_started — заблокированный пользователь
|
||||
BLOCKED = (
|
||||
"⛔ Ваш номер не найден в системе. Доступ заблокирован.\n"
|
||||
"Обратитесь к администратору для подключения."
|
||||
)
|
||||
# /start — заблокированный пользователь
|
||||
BLOCKED_SHORT = "⛔ Доступ заблокирован. Обратитесь к администратору."
|
||||
# bot_started — новый пользователь
|
||||
WELCOME_NEW = (
|
||||
"👋 Добро пожаловать в систему технической поддержки!\n\n"
|
||||
"Для входа нажмите кнопку ниже и поделитесь своим номером телефона."
|
||||
)
|
||||
# /start — новый пользователь
|
||||
WELCOME_START = (
|
||||
"👋 Добро пожаловать!\n\n"
|
||||
"Поделитесь номером телефона для доступа к системе."
|
||||
)
|
||||
# /start — пользователь не авторизован
|
||||
SHARE_PHONE = "Поделитесь номером телефона для доступа:"
|
||||
# bot_started — возвращающийся пользователь (префикс перед текстом меню)
|
||||
WELCOME_BACK = "👋 С возвращением!\n\n"
|
||||
# Успешная авторизация (префикс перед текстом меню)
|
||||
AUTHORIZED = "✅ Вы успешно авторизованы!\n\n"
|
||||
# Ошибки при шаринге контакта
|
||||
CONTACT_READ_ERROR = "Не удалось прочитать контакт. Попробуйте ещё раз."
|
||||
CONTACT_NO_PHONE = "В контакте не найден номер телефона. Попробуйте ещё раз."
|
||||
# Загрузка после шаринга номера
|
||||
LOADING = "⏳ Проверяем номер..."
|
||||
# Телефон не найден в белом списке
|
||||
PHONE_NOT_FOUND = (
|
||||
"⛔ Ваш номер не найден в системе.\n"
|
||||
"Доступ заблокирован. Обратитесь к администратору для подключения."
|
||||
)
|
||||
|
||||
|
||||
class TicketTexts:
|
||||
# Шаг 1 — приглашение описать проблему
|
||||
ENTER_DESCRIPTION = (
|
||||
"📝 Опишите вашу проблему.\n\n"
|
||||
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
|
||||
"Когда закончите — нажмите «👁 Перейти к предпросмотру»."
|
||||
)
|
||||
# Прислали что-то без текста и без вложений
|
||||
NEED_TEXT = "❗ Пожалуйста, напишите описание проблемы текстом."
|
||||
# Прислали файл, но нет текста
|
||||
FILE_RECEIVED_NEED_TEXT = "✅ Вложение принято! Теперь напишите описание проблемы текстом:"
|
||||
# Описание принято, можно добавить ещё
|
||||
DESCRIPTION_ACCEPTED = (
|
||||
"✏️ Описание принято. Вы можете добавить ещё файлы "
|
||||
"или нажмите «👁 Перейти к предпросмотру»."
|
||||
)
|
||||
# Нажали «Готово» без описания — уведомление
|
||||
PREVIEW_NEED_TEXT_NOTIF = "Сначала опишите проблему!"
|
||||
# Нажали «Готово» без описания — сообщение
|
||||
PREVIEW_NEED_TEXT = "❗ Пожалуйста, сначала напишите описание проблемы."
|
||||
# Вернулись к редактированию из предпросмотра
|
||||
CONTINUE_EDITING = (
|
||||
"✏️ Продолжайте добавлять описание или вложения.\n"
|
||||
"Нажмите «👁 Перейти к предпросмотру» когда будете готовы."
|
||||
)
|
||||
# Уведомление при отмене создания
|
||||
CANCEL_NOTIF = "Создание отменено"
|
||||
# Отправка заявки
|
||||
SENDING = "⏳ Отправляем заявку…"
|
||||
# Успешное создание
|
||||
CREATED = "✅ Заявка успешно создана!"
|
||||
# Номер созданной заявки: .format(number=n)
|
||||
CREATED_NUMBER = "Номер заявки: #{number}"
|
||||
# Подпись после создания
|
||||
CREATED_CONTACT = "Наши специалисты свяжутся с вами в ближайшее время."
|
||||
|
||||
# Предпросмотр
|
||||
PREVIEW_HEADER = "📋 Предпросмотр заявки:"
|
||||
PREVIEW_ATTACHMENTS_HEADER = "📎 Вложения:"
|
||||
PREVIEW_CONFIRM_PROMPT = "Всё верно? Подтвердите или вернитесь к редактированию."
|
||||
|
||||
# Подписи вложений в предпросмотре
|
||||
ATT_PHOTO = "🖼 Фото"
|
||||
ATT_VIDEO = "📹 Видео"
|
||||
ATT_AUDIO = "🎤 Голосовое"
|
||||
ATT_AUDIO_DUR = "🎤 Голосовое ({mins:02d}:{secs:02d})"
|
||||
ATT_FILE = "📎 {fname}"
|
||||
ATT_FILE_UNNAMED = "📎 Файл"
|
||||
ATT_OTHER = "📎 Вложение"
|
||||
|
||||
|
||||
class TicketListTexts:
|
||||
LOADING = "⏳ Загружаем список заявок..."
|
||||
NO_ORG = "⚠️ Ваша организация не привязана к Intradesk. Обратитесь к администратору."
|
||||
EMPTY = "📭 Активных заявок нет."
|
||||
# Заголовок списка: .format(total=n, page=n, pages=n)
|
||||
HEADER = "📋 Активные заявки (всего: {total}) — стр. {page}/{pages}"
|
||||
NO_COMMENTS = "💬 Нет комментариев"
|
||||
|
||||
|
||||
class TicketDetailTexts:
|
||||
LOADING = "⏳ Загружаем заявку..."
|
||||
NOT_FOUND = "⚠️ Заявка не найдена. Вернитесь к списку."
|
||||
DESCRIPTION_HEADER = "📝 Описание:"
|
||||
COMMENTS_HEADER = "💬 Комментарии ({count}):"
|
||||
NO_COMMENTS = "💬 Комментариев нет"
|
||||
# Закрытие заявки
|
||||
CLOSING = "⏳ Закрываем заявку..."
|
||||
# .format(num_str=" #123" или "")
|
||||
CLOSED = "✅ Заявка{num_str} закрыта."
|
||||
CLOSE_ERROR = "⚠️ Не удалось закрыть заявку. Попробуйте позже."
|
||||
CLOSE_NUMBER_ERROR = "⚠️ Не удалось определить номер заявки. Вернитесь к списку."
|
||||
GENERIC_ERROR = "Ошибка"
|
||||
|
||||
|
||||
class CommentTexts:
|
||||
# .format(num_str=" к заявке #123" или "")
|
||||
PROMPT = (
|
||||
"💬 Напишите комментарий{num_str}:\n\n"
|
||||
"Можно отправить текст, файл, фото или голосовое сообщение.\n"
|
||||
"Нажмите «← Назад к заявке» для отмены."
|
||||
)
|
||||
EMPTY_ERROR = "❗ Комментарий не может быть пустым. Напишите текст или прикрепите файл:"
|
||||
FILE_ONLY = "📎 Файл прикреплён"
|
||||
# .format(num_str=" к заявке #123" или "")
|
||||
SUCCESS = "✅ Комментарий{num_str} успешно отправлен!"
|
||||
ERROR = "⚠️ Не удалось отправить комментарий. Попробуйте позже."
|
||||
|
||||
|
||||
class MenuTexts:
|
||||
LOADING = "⏳ Загружаем меню..."
|
||||
SEPARATOR = "━" * 16
|
||||
# .format(org_name="...")
|
||||
ORG_HEADER = "🏢 {org_name}"
|
||||
NO_ORG_HEADER = "🏠 Поддержка"
|
||||
NO_TICKETS = "📭 Активных заявок нет"
|
||||
NO_TICKETS_HINT = "Нажмите «Создать заявку» и мы\nсвяжемся с вами в ближайшее время."
|
||||
# .format(active=n)
|
||||
ACTIVE_COUNT = "📋 Активных заявок: {active}"
|
||||
# .format(num_str=" (заявка #123)" или "")
|
||||
LAST_COMMENT = "💬 Последний ответ{num_str}:"
|
||||
# .format(author="...", short_date="...")
|
||||
LAST_COMMENT_AUTHOR = "— {author}, {short_date}"
|
||||
NO_NEW_COMMENTS = "💬 Новых ответов нет"
|
||||
NO_ORG_DATA = "📭 Нет данных об организации"
|
||||
CHOOSE_ACTION = "Выберите действие 👇"
|
||||
# Префикс при /cancel с активным флоу
|
||||
CANCEL_PREFIX = "❌ Действие отменено.\n\n"
|
||||
|
||||
|
||||
class QuickReplyTexts:
|
||||
# .format(number=task_number, text=user_text)
|
||||
CONFIRM = (
|
||||
"📨 Отправить как ответ на заявку #{number}?\n\n"
|
||||
"«{text}»"
|
||||
)
|
||||
# .format(number=task_number)
|
||||
SENT = "✅ Ответ на заявку #{number} отправлен."
|
||||
ERROR = "⚠️ Не удалось отправить ответ. Попробуйте через меню."
|
||||
|
||||
|
||||
class Btn:
|
||||
"""Подписи всех inline-кнопок."""
|
||||
# Главное меню
|
||||
CREATE_TICKET = "📝 Создать заявку"
|
||||
VIEW_TICKETS = "📋 Мои заявки"
|
||||
MAIN_MENU = "🏠 Главное меню"
|
||||
# Авторизация
|
||||
SHARE_CONTACT = "📱 Поделиться контактом"
|
||||
# Создание заявки
|
||||
DONE_TICKET = "✅ Готово"
|
||||
CONFIRM_TICKET = "✅ Подтвердить"
|
||||
EDIT_TICKET = "✏️ Продолжить редактирование"
|
||||
CANCEL_TICKET = "❌ Отменить создание"
|
||||
# Навигация по заявкам
|
||||
PREV_PAGE = "← Пред."
|
||||
NEXT_PAGE = "След. →"
|
||||
BACK_TO_MENU = "← Назад в меню"
|
||||
BACK_TO_LIST = "← К списку заявок"
|
||||
BACK_TO_TICKET = "← Назад к заявке"
|
||||
BACK_TO_TICKETS = "← К списку"
|
||||
# Карточка заявки
|
||||
ADD_COMMENT = "💬 Оставить комментарий"
|
||||
CLOSE_TICKET = "🔒 Закрыть заявку"
|
||||
# Уведомления и быстрый ответ
|
||||
REPLY_TO_TICKET = "💬 Ответить"
|
||||
OPEN_TICKET = "📋 К заявке"
|
||||
CONFIRM_REPLY = "✅ Отправить"
|
||||
CANCEL_REPLY = "❌ Отмена"
|
||||
CHOOSE_TICKET = "📋 Выбрать другую заявку"
|
||||
BIN
data/support.db
BIN
data/support.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -60,6 +60,17 @@ async def init_db() -> None:
|
|||
)
|
||||
await _db.commit()
|
||||
|
||||
# Таблица контекста быстрого ответа (последняя активная заявка пользователя)
|
||||
await _db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_reply_context (
|
||||
max_user_id INTEGER PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL,
|
||||
task_number INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
await _db.commit()
|
||||
|
||||
|
||||
def get_db() -> aiosqlite.Connection:
|
||||
if _db is None:
|
||||
|
|
|
|||
|
|
@ -93,6 +93,16 @@ async def delete_bot_user(user_id: int) -> None:
|
|||
await db.commit()
|
||||
|
||||
|
||||
async def change_org_bot_user(user_id: int, org_id: int) -> None:
|
||||
"""Сменить организацию авторизованного пользователя."""
|
||||
db = get_db()
|
||||
await db.execute(
|
||||
"UPDATE bot_users SET org_id = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(org_id, user_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def unblock_bot_user(user_id: int, org_id: int) -> None:
|
||||
db = get_db()
|
||||
await db.execute(
|
||||
|
|
|
|||
48
database/queries/reply_context.py
Normal file
48
database/queries/reply_context.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Контекст быстрого ответа: последняя активная заявка пользователя."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from database.connection import get_db
|
||||
|
||||
_TTL_MINUTES = 60
|
||||
|
||||
|
||||
async def set_reply_context(max_user_id: int, task_id: int, task_number: int | None) -> None:
|
||||
"""Записать / обновить последнюю активную заявку пользователя."""
|
||||
db = get_db()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO user_reply_context (max_user_id, task_id, task_number, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(max_user_id) DO UPDATE SET
|
||||
task_id = excluded.task_id,
|
||||
task_number = excluded.task_number,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(max_user_id, task_id, task_number),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_reply_context(max_user_id: int) -> dict | None:
|
||||
"""Вернуть контекст если он свежий (< TTL минут), иначе None."""
|
||||
db = get_db()
|
||||
async with db.execute(
|
||||
"SELECT task_id, task_number, updated_at FROM user_reply_context WHERE max_user_id = ?",
|
||||
(max_user_id,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(row["updated_at"]).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
if datetime.now(tz=timezone.utc) - updated_at > timedelta(minutes=_TTL_MINUTES):
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def clear_reply_context(max_user_id: int) -> None:
|
||||
db = get_db()
|
||||
await db.execute("DELETE FROM user_reply_context WHERE max_user_id = ?", (max_user_id,))
|
||||
await db.commit()
|
||||
|
|
@ -6,3 +6,4 @@ aiosqlite
|
|||
python-dotenv
|
||||
python-multipart
|
||||
httpx>=0.27.0
|
||||
brotli
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue