diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dd0d9ef..4a9bca7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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''\\)\")" ] } } diff --git a/admin/routes/bot_users.py b/admin/routes/bot_users.py index 3274ef5..91e928e 100644 --- a/admin/routes/bot_users.py +++ b/admin/routes/bot_users.py @@ -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): """Сбрасывает авторизацию — пользователь должен заново авторизоваться через бот.""" diff --git a/admin/templates/bot_users/list.html b/admin/templates/bot_users/list.html index 74189be..2467b7f 100644 --- a/admin/templates/bot_users/list.html +++ b/admin/templates/bot_users/list.html @@ -65,6 +65,19 @@ {% endif %} + {# Сменить организацию — только для авторизованных и незаблокированных #} + {% if u.is_authorized and not u.is_blocked %} +
+ + +
+ {% endif %} + {# Сбросить авторизацию — для авторизованных и заблокированных (повторная auth через бот) #} {% if u.is_authorized or u.is_blocked %}
diff --git a/bot/flow_config.py b/bot/flow_config.py new file mode 100644 index 0000000..e38cde9 --- /dev/null +++ b/bot/flow_config.py @@ -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, +} diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index 21c8865..c32f983 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -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), - attachments=[main_menu_kb()], + text=MenuTexts.LOADING, ) - await nav_set(context, menu_mid) + + menu_text = await build_main_menu_text(bot_user) + edited = False + if loading_mid: + try: + await event.bot.edit_message( + message_id=loading_mid, + text=menu_text, + attachments=[main_menu_kb()], + ) + edited = True + await nav_set(context, loading_mid) + except Exception: + pass + if not edited: + mid = await bot_send( + event.bot, + chat_id=chat_id, + user_id=bot_user["max_user_id"], + text=menu_text, + attachments=[main_menu_kb()], + ) + await nav_set(context, mid) # ── Шаг 5а: "Подтвердить" → создание ───────────────────────────────────────── @router.message_callback( - F.callback.payload == "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), - attachments=[main_menu_kb()], - ) - await nav_set(context, menu_mid) + final_text = "\n".join(lines) + edited = False + if loading_mid: + try: + await event.bot.edit_message( + message_id=loading_mid, + text=final_text, + attachments=[main_menu_kb()], + ) + edited = True + await nav_set(context, loading_mid) + except Exception: + pass + if not edited: + mid = await bot_send( + event.bot, + chat_id=chat_id, + user_id=bot_user["max_user_id"], + text=final_text, + attachments=[main_menu_kb()], + ) + await nav_set(context, mid) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 96de0c2..5f14f01 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -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"], ) diff --git a/bot/handlers/quick_reply.py b/bot/handlers/quick_reply.py new file mode 100644 index 0000000..45fd330 --- /dev/null +++ b/bot/handlers/quick_reply.py @@ -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) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 1927d4c..d9abe95 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -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( diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index b1fa3b1..c794aed 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -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="⚠️ Не удалось отправить комментарий. Попробуйте позже.", - ) + num_str = f" к заявке #{task_number}" if task_number else "" + 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) diff --git a/bot/keyboards.py b/bot/keyboards.py index 6dc19f5..8852791 100644 --- a/bot/keyboards.py +++ b/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() ) diff --git a/bot/notifier.py b/bot/notifier.py index 6efdd07..0c3f9c1 100644 --- a/bot/notifier.py +++ b/bot/notifier.py @@ -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", diff --git a/bot/setup.py b/bot/setup.py index eb14788..5dab3af 100644 --- a/bot/setup.py +++ b/bot/setup.py @@ -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 diff --git a/bot/states.py b/bot/states.py index 2bcb56c..55ac5c8 100644 --- a/bot/states.py +++ b/bot/states.py @@ -9,6 +9,7 @@ class TicketStates(StatesGroup): waiting_description = State() # Ожидаем первое сообщение с описанием collecting_attachments = State() # Накапливаем доп. файлы/текст confirming = State() # Предпросмотр — ждём подтверждения + pending_confirm = State() # Быстрый ответ — ждём подтверждения отправки class CommentStates(StatesGroup): diff --git a/bot/texts.py b/bot/texts.py new file mode 100644 index 0000000..19a14c7 --- /dev/null +++ b/bot/texts.py @@ -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 = "📋 Выбрать другую заявку" diff --git a/data/support.db b/data/support.db index 915a170..a36e488 100644 Binary files a/data/support.db and b/data/support.db differ diff --git a/data/support.db-shm b/data/support.db-shm index 34b6766..0a88246 100644 Binary files a/data/support.db-shm and b/data/support.db-shm differ diff --git a/data/support.db-wal b/data/support.db-wal index 17a6c7d..e705ef8 100644 Binary files a/data/support.db-wal and b/data/support.db-wal differ diff --git a/database/connection.py b/database/connection.py index a091fac..bb3f6c7 100644 --- a/database/connection.py +++ b/database/connection.py @@ -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: diff --git a/database/queries/bot_users.py b/database/queries/bot_users.py index 6902b69..c3ab716 100644 --- a/database/queries/bot_users.py +++ b/database/queries/bot_users.py @@ -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( diff --git a/database/queries/reply_context.py b/database/queries/reply_context.py new file mode 100644 index 0000000..09bbcd5 --- /dev/null +++ b/database/queries/reply_context.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 65af254..3dd7d00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ aiosqlite python-dotenv python-multipart httpx>=0.27.0 +brotli