Compare commits
No commits in common. "3851baa38cd9806f12bb030b3423530a239f7b03" and "547a4a1dcb68b65803e1142fbd641b2a0118c676" have entirely different histories.
3851baa38c
...
547a4a1dcb
33 changed files with 284 additions and 2619 deletions
|
|
@ -6,16 +6,7 @@
|
||||||
"Bash(python -c \"import maxapi; import inspect; members = [m for m in dir\\(maxapi\\) if not m.startswith\\(''_''\\)]; print\\(members\\)\")",
|
"Bash(python -c \"import maxapi; import inspect; members = [m for m in dir\\(maxapi\\) if not m.startswith\\(''_''\\)]; print\\(members\\)\")",
|
||||||
"Bash(python -c \"from maxapi import Bot; methods = [m for m in dir\\(Bot\\) if not m.startswith\\(''_''\\)]; print\\(methods\\)\")",
|
"Bash(python -c \"from maxapi import Bot; methods = [m for m in dir\\(Bot\\) if not m.startswith\\(''_''\\)]; print\\(methods\\)\")",
|
||||||
"Bash(python -c \"from maxapi import Bot; import inspect; print\\(inspect.signature\\(Bot.edit_message\\)\\)\")",
|
"Bash(python -c \"from maxapi import Bot; import inspect; print\\(inspect.signature\\(Bot.edit_message\\)\\)\")",
|
||||||
"Bash(python -c \"from maxapi.types.updates.message_callback import MessageCallback; print\\([m for m in dir\\(MessageCallback\\) if not m.startswith\\(''_''\\)]\\)\")",
|
"Bash(python -c \"from maxapi.types.updates.message_callback import MessageCallback; print\\([m for m in dir\\(MessageCallback\\) if not m.startswith\\(''_''\\)]\\)\")"
|
||||||
"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(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''\\)\")",
|
|
||||||
"Bash(find /c/Users/Professional/Documents/VSCode/support_bot -type d -not -path */.* -not -path */__pycache__* -not -path */data/*)",
|
|
||||||
"Bash(wc -l /c/Users/Professional/Documents/VSCode/support_bot/**/*.py)"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
.env
2
.env
|
|
@ -23,5 +23,3 @@ INTRADESK_CHANGES_API_KEY=26221B839B6643E68345FD26BF
|
||||||
INTRADESK_DEFAULT_SERVICE=84681/84683/
|
INTRADESK_DEFAULT_SERVICE=84681/84683/
|
||||||
# ID типа заявки (Настройки → Заявки → Типы заявок → ID)
|
# ID типа заявки (Настройки → Заявки → Типы заявок → ID)
|
||||||
INTRADESK_DEFAULT_TASK_TYPE=21026
|
INTRADESK_DEFAULT_TASK_TYPE=21026
|
||||||
INTRADESK_CLOSE_STATUS_ID=68045
|
|
||||||
NOTIFICATION_POLL_INTERVAL=60
|
|
||||||
37
.env.example
37
.env.example
|
|
@ -1,50 +1,19 @@
|
||||||
# ── Telegram Bot ──────────────────────────────────────────────────────────────
|
|
||||||
MAX_BOT_TOKEN=your_token_here
|
MAX_BOT_TOKEN=your_token_here
|
||||||
|
|
||||||
# ── База данных ────────────────────────────────────────────────────────────────
|
|
||||||
# При локальном запуске (python main.py):
|
# При локальном запуске (python main.py):
|
||||||
DB_PATH=data/support.db
|
DB_PATH=data/support.db
|
||||||
|
|
||||||
# При запуске через Docker DB_PATH переопределяется в docker-compose.yml
|
# При запуске через Docker DB_PATH переопределяется в docker-compose.yml
|
||||||
# и указывает на /app/data/support.db (volume ./data/)
|
# и указывает на /app/data/support.db (volume ./data/)
|
||||||
|
|
||||||
# ── Админ-панель ───────────────────────────────────────────────────────────────
|
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=changeme
|
ADMIN_PASSWORD=changeme
|
||||||
ADMIN_HOST=0.0.0.0
|
ADMIN_HOST=0.0.0.0
|
||||||
ADMIN_PORT=8080
|
ADMIN_PORT=8080
|
||||||
|
|
||||||
# ── Intradesk API ──────────────────────────────────────────────────────────────
|
# Intradesk API
|
||||||
INTRADESK_BASE_URL=https://apigw.intradesk.ru
|
INTRADESK_BASE_URL=https://apigw.intradesk.ru
|
||||||
|
|
||||||
# Ключ для /settings/odata (клиенты, пользователи)
|
|
||||||
INTRADESK_API_KEY=your_settings_api_key
|
INTRADESK_API_KEY=your_settings_api_key
|
||||||
|
|
||||||
# Ключ для /tasklist/odata (чтение заявок) и /taskhistory (история/комментарии)
|
|
||||||
INTRADESK_TASKS_API_KEY=your_tasks_api_key
|
INTRADESK_TASKS_API_KEY=your_tasks_api_key
|
||||||
|
|
||||||
# Ключ для /changes/v3/tasks (создание и изменение заявок)
|
|
||||||
INTRADESK_CHANGES_API_KEY=your_changes_api_key
|
|
||||||
|
|
||||||
# Путь сервиса для новых заявок (Настройки → Сервисы → «Путь сервиса», напр. "84681/84683/")
|
|
||||||
INTRADESK_DEFAULT_SERVICE=
|
|
||||||
|
|
||||||
# ID типа заявки по умолчанию (Настройки → Заявки → Типы заявок → ID)
|
|
||||||
INTRADESK_DEFAULT_TASK_TYPE=
|
|
||||||
|
|
||||||
# ID статуса «Выполнена» для кнопки «Закрыть заявку» (Настройки → Справочники → Статусы → ID)
|
|
||||||
INTRADESK_CLOSE_STATUS_ID=
|
|
||||||
|
|
||||||
# ── Уведомления ────────────────────────────────────────────────────────────────
|
|
||||||
# Интервал опроса Intradesk для push-уведомлений, секунды (0 — отключить)
|
|
||||||
NOTIFICATION_POLL_INTERVAL=120
|
|
||||||
|
|
||||||
# ── Фильтрация заявок ──────────────────────────────────────────────────────────
|
|
||||||
# ID группы исполнителей (0 = все группы)
|
|
||||||
INTRADESK_EXECUTOR_GROUP=0
|
INTRADESK_EXECUTOR_GROUP=0
|
||||||
|
INTRADESK_EXECUTORS=id1,id2,id3
|
||||||
# Список ID исполнителей через запятую (пусто = все исполнители)
|
|
||||||
INTRADESK_EXECUTORS=
|
|
||||||
|
|
||||||
# ── Прочее ────────────────────────────────────────────────────────────────────
|
|
||||||
# Dry-run: не писать в Intradesk, только выводить payload в консоль
|
|
||||||
DRY_RUN=false
|
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,4 +1,2 @@
|
||||||
.env
|
.env
|
||||||
__pycache__
|
__pycache__
|
||||||
data/support.db-wal
|
|
||||||
data/support.db-shm
|
|
||||||
|
|
|
||||||
14
Dockerfile
14
Dockerfile
|
|
@ -1,12 +1,7 @@
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# curl нужен для healthcheck
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -19,9 +14,4 @@ RUN mkdir -p /app/data
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Проверяем, что admin-панель отвечает (401 = нормально, требует авторизацию)
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|
||||||
CMD curl -sf -o /dev/null -w "%{http_code}" http://localhost:8080/admin/ \
|
|
||||||
| grep -qE "^(200|301|302|401)" || exit 1
|
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from admin.auth import require_admin
|
from admin.auth import require_admin
|
||||||
from admin.templates_env import templates
|
from admin.templates_env import templates
|
||||||
from database.queries.bot_users import (
|
from database.queries.bot_users import (
|
||||||
change_org_bot_user,
|
|
||||||
delete_bot_user,
|
delete_bot_user,
|
||||||
get_all_bot_users,
|
get_all_bot_users,
|
||||||
reset_bot_user,
|
reset_bot_user,
|
||||||
|
|
@ -47,12 +46,6 @@ async def unblock_user(user_id: int, org_id: int = Form(...)):
|
||||||
return RedirectResponse("/admin/bot-users", status_code=303)
|
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")
|
@router.post("/{user_id}/reset")
|
||||||
async def reset_user(user_id: int):
|
async def reset_user(user_id: int):
|
||||||
"""Сбрасывает авторизацию — пользователь должен заново авторизоваться через бот."""
|
"""Сбрасывает авторизацию — пользователь должен заново авторизоваться через бот."""
|
||||||
|
|
|
||||||
|
|
@ -65,19 +65,6 @@
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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 через бот) #}
|
{# Сбросить авторизацию — для авторизованных и заблокированных (повторная auth через бот) #}
|
||||||
{% if u.is_authorized or u.is_blocked %}
|
{% if u.is_authorized or u.is_blocked %}
|
||||||
<form action="/admin/bot-users/{{ u.id }}/reset" method="post">
|
<form action="/admin/bot-users/{{ u.id }}/reset" method="post">
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
"""
|
|
||||||
Конфигурация 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,73 +21,22 @@ from maxapi.types.attachments.attachment import (
|
||||||
from maxapi.types.updates.message_callback import MessageCallback
|
from maxapi.types.updates.message_callback import MessageCallback
|
||||||
from maxapi.types.updates.message_created import MessageCreated
|
from maxapi.types.updates.message_created import MessageCreated
|
||||||
|
|
||||||
from bot.flow_config import PAYLOADS, TRANSITIONS
|
|
||||||
from bot.handlers.menu import build_main_menu_text
|
from bot.handlers.menu import build_main_menu_text
|
||||||
from bot.keyboards import confirm_ticket_kb, done_ticket_kb, main_menu_kb
|
from bot.keyboards import confirm_ticket_kb, done_ticket_kb, main_menu_kb
|
||||||
from bot.logging_setup import get_logger
|
from bot.logging_setup import get_logger
|
||||||
from bot.nav_helper import nav_set
|
|
||||||
from bot.send_helper import bot_send
|
from bot.send_helper import bot_send
|
||||||
from bot.texts import MenuTexts, TicketTexts
|
from bot.states import TicketStates
|
||||||
from database.connection import get_db
|
from database.connection import get_db
|
||||||
from database.queries.intradesk import get_intradesk_contact_id_for_phone
|
from database.queries.intradesk import get_intradesk_contact_id_for_phone
|
||||||
from database.queries.organizations import get_organization
|
from database.queries.organizations import get_organization
|
||||||
from database.queries.ticket_attachments import add_attachment
|
from database.queries.ticket_attachments import add_attachment
|
||||||
from database.queries.tickets import create_ticket, set_intradesk_task_id
|
from database.queries.tickets import create_ticket, set_intradesk_task_id
|
||||||
from external.intradesk_api import create_intradesk_task, invalidate_client_cache, upload_attachments
|
from external.intradesk_api import create_intradesk_task
|
||||||
|
|
||||||
logger = get_logger("bot.handlers.create_ticket")
|
logger = get_logger("bot.handlers.create_ticket")
|
||||||
|
|
||||||
router = Router(router_id="create_ticket")
|
router = Router(router_id="create_ticket")
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_ticket_flow(bot, context: BaseContext) -> None:
|
|
||||||
"""
|
|
||||||
Удалить висящие сообщения незавершённого потока создания заявки.
|
|
||||||
Вызывается из любого обработчика, который прерывает этот поток
|
|
||||||
(главное меню, список заявок, повторное нажатие «Создать заявку»).
|
|
||||||
"""
|
|
||||||
from bot.states import TicketStates, CommentStates # avoid circular at module level
|
|
||||||
state = await context.get_state()
|
|
||||||
creation_states = [
|
|
||||||
TicketStates.waiting_description,
|
|
||||||
TicketStates.collecting_attachments,
|
|
||||||
TicketStates.confirming,
|
|
||||||
CommentStates.waiting_text,
|
|
||||||
]
|
|
||||||
if state not in creation_states:
|
|
||||||
return
|
|
||||||
data = await context.get_data()
|
|
||||||
for mid in (data.get("flow_message_ids") or []):
|
|
||||||
try:
|
|
||||||
await bot.delete_message(message_id=mid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await context.clear()
|
|
||||||
|
|
||||||
|
|
||||||
async def _track_mid(context: BaseContext, mid: str | None) -> None:
|
|
||||||
"""Добавить message_id в список сообщений потока создания."""
|
|
||||||
if not mid:
|
|
||||||
return
|
|
||||||
data = await context.get_data()
|
|
||||||
ids: list[str] = data.get("flow_message_ids") or []
|
|
||||||
ids.append(mid)
|
|
||||||
data["flow_message_ids"] = ids
|
|
||||||
await context.set_data(data)
|
|
||||||
|
|
||||||
|
|
||||||
async def _delete_flow_messages(bot, context: BaseContext, extra_mid: str | None = None) -> None:
|
|
||||||
"""Удалить все сообщения, накопленные за время потока создания."""
|
|
||||||
data = await context.get_data()
|
|
||||||
ids: list[str] = list(data.get("flow_message_ids") or [])
|
|
||||||
if extra_mid:
|
|
||||||
ids.append(extra_mid)
|
|
||||||
for mid in ids:
|
|
||||||
try:
|
|
||||||
await bot.delete_message(message_id=mid)
|
|
||||||
except Exception:
|
|
||||||
pass # сообщение уже удалено или слишком старое
|
|
||||||
|
|
||||||
_MEDIA_TYPES = {
|
_MEDIA_TYPES = {
|
||||||
AttachmentType.IMAGE,
|
AttachmentType.IMAGE,
|
||||||
AttachmentType.VIDEO,
|
AttachmentType.VIDEO,
|
||||||
|
|
@ -132,50 +81,47 @@ def _att_label(att: dict) -> str:
|
||||||
"""Человекочитаемое описание вложения для предпросмотра."""
|
"""Человекочитаемое описание вложения для предпросмотра."""
|
||||||
t = att.get("type", "")
|
t = att.get("type", "")
|
||||||
if AttachmentType.IMAGE.value in t or "PHOTO" in t.upper():
|
if AttachmentType.IMAGE.value in t or "PHOTO" in t.upper():
|
||||||
return TicketTexts.ATT_PHOTO
|
return "🖼 Фото"
|
||||||
if AttachmentType.VIDEO.value in t:
|
if AttachmentType.VIDEO.value in t:
|
||||||
return TicketTexts.ATT_VIDEO
|
return "📹 Видео"
|
||||||
if AttachmentType.AUDIO.value in t:
|
if AttachmentType.AUDIO.value in t:
|
||||||
dur = att.get("duration")
|
dur = att.get("duration")
|
||||||
if dur:
|
if dur:
|
||||||
mins, secs = divmod(int(dur), 60)
|
mins, secs = divmod(int(dur), 60)
|
||||||
return TicketTexts.ATT_AUDIO_DUR.format(mins=mins, secs=secs)
|
return f"🎤 Голосовое ({mins:02d}:{secs:02d})"
|
||||||
return TicketTexts.ATT_AUDIO
|
return "🎤 Голосовое"
|
||||||
if AttachmentType.FILE.value in t:
|
if AttachmentType.FILE.value in t:
|
||||||
fname = att.get("filename")
|
fname = att.get("filename")
|
||||||
return TicketTexts.ATT_FILE.format(fname=fname) if fname else TicketTexts.ATT_FILE_UNNAMED
|
return f"📎 {fname}" if fname else "📎 Файл"
|
||||||
return TicketTexts.ATT_OTHER
|
return "📎 Вложение"
|
||||||
|
|
||||||
|
|
||||||
def _preview_text(org_name: str | None, description: str, pending_atts: list[dict]) -> str:
|
def _preview_text(org_name: str | None, description: str, pending_atts: list[dict]) -> str:
|
||||||
"""Формирует текст предпросмотра заявки."""
|
"""Формирует текст предпросмотра заявки."""
|
||||||
task_name = org_name or description[:80]
|
task_name = org_name or description[:80]
|
||||||
lines = [
|
lines = [
|
||||||
TicketTexts.PREVIEW_HEADER,
|
"📋 Предпросмотр заявки:",
|
||||||
"",
|
"",
|
||||||
f"📄 {task_name}",
|
f"📄 {task_name}",
|
||||||
"",
|
"",
|
||||||
description[:500] + ("…" if len(description) > 500 else ""),
|
description[:500] + ("…" if len(description) > 500 else ""),
|
||||||
]
|
]
|
||||||
if pending_atts:
|
if pending_atts:
|
||||||
lines += ["", TicketTexts.PREVIEW_ATTACHMENTS_HEADER]
|
lines += ["", "📎 Вложения:"]
|
||||||
for att in pending_atts:
|
for att in pending_atts:
|
||||||
lines.append(f" • {_att_label(att)}")
|
lines.append(f" • {_att_label(att)}")
|
||||||
lines += ["", TicketTexts.PREVIEW_CONFIRM_PROMPT]
|
lines += ["", "Всё верно? Подтвердите или вернитесь к редактированию."]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# ── Шаг 1: начало создания ────────────────────────────────────────────────────
|
# ── Шаг 1: начало создания ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.message_callback(F.callback.payload == PAYLOADS.CREATE_TICKET)
|
@router.message_callback(F.callback.payload == "create_ticket")
|
||||||
async def cb_create_ticket(
|
async def cb_create_ticket(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
# Зачищаем предыдущий незавершённый поток, если был
|
await context.set_state(TicketStates.waiting_description)
|
||||||
await cleanup_ticket_flow(event.bot, context)
|
await context.set_data({"pending_attachments": []})
|
||||||
|
|
||||||
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"])
|
logger.info("📝 create_ticket started | user_id=%s", bot_user["max_user_id"])
|
||||||
await event.answer(notification="")
|
await event.answer(notification="")
|
||||||
|
|
||||||
|
|
@ -183,19 +129,22 @@ async def cb_create_ticket(
|
||||||
if chat_id is None:
|
if chat_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
mid = await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
text=TicketTexts.ENTER_DESCRIPTION,
|
text=(
|
||||||
|
"📝 Опишите вашу проблему.\n\n"
|
||||||
|
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
|
||||||
|
"Когда закончите — нажмите кнопку «Готово»."
|
||||||
|
),
|
||||||
attachments=[done_ticket_kb()],
|
attachments=[done_ticket_kb()],
|
||||||
)
|
)
|
||||||
await _track_mid(context, mid)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Шаг 2: первое сообщение с описанием ──────────────────────────────────────
|
# ── Шаг 2: первое сообщение с описанием ──────────────────────────────────────
|
||||||
|
|
||||||
@router.message_created(states=TRANSITIONS["ticket:start"])
|
@router.message_created(states=TicketStates.waiting_description)
|
||||||
async def on_ticket_description(
|
async def on_ticket_description(
|
||||||
event: MessageCreated, context: BaseContext
|
event: MessageCreated, context: BaseContext
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -205,56 +154,26 @@ async def on_ticket_description(
|
||||||
|
|
||||||
body = event.message.body
|
body = event.message.body
|
||||||
text = (body.text or "").strip() if body else ""
|
text = (body.text or "").strip() if body else ""
|
||||||
new_atts = _extract_attachments(event)
|
attachments = _extract_attachments(event)
|
||||||
|
|
||||||
if not text:
|
await context.update_data(description=text, pending_attachments=attachments)
|
||||||
if not new_atts:
|
await context.set_state(TicketStates.collecting_attachments)
|
||||||
# Стикер/локация/etc — просим написать описание
|
|
||||||
mid = await bot_send(
|
|
||||||
event.bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=user_id,
|
|
||||||
text=TicketTexts.NEED_TEXT,
|
|
||||||
attachments=[done_ticket_kb()],
|
|
||||||
)
|
|
||||||
await _track_mid(context, mid)
|
|
||||||
return
|
|
||||||
# Файл без текста — сохраняем, остаёмся в waiting_description, просим текст
|
|
||||||
data = await context.get_data()
|
|
||||||
existing = data.get("pending_attachments") or []
|
|
||||||
existing.extend(new_atts)
|
|
||||||
await context.update_data(pending_attachments=existing)
|
|
||||||
mid = await bot_send(
|
|
||||||
event.bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=user_id,
|
|
||||||
text=TicketTexts.FILE_RECEIVED_NEED_TEXT,
|
|
||||||
attachments=[done_ticket_kb()],
|
|
||||||
)
|
|
||||||
await _track_mid(context, mid)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Есть текст — объединяем с ранее добавленными вложениями
|
await bot_send(
|
||||||
data = await context.get_data()
|
|
||||||
existing_atts = data.get("pending_attachments") or []
|
|
||||||
existing_atts.extend(new_atts)
|
|
||||||
|
|
||||||
await context.update_data(description=text, pending_attachments=existing_atts)
|
|
||||||
await context.set_state(TRANSITIONS["ticket:description_ok"])
|
|
||||||
|
|
||||||
mid = await bot_send(
|
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=TicketTexts.DESCRIPTION_ACCEPTED,
|
text=(
|
||||||
|
"✏️ Описание принято. Вы можете добавить ещё файлы "
|
||||||
|
"или нажмите «Готово» для отправки заявки."
|
||||||
|
),
|
||||||
attachments=[done_ticket_kb()],
|
attachments=[done_ticket_kb()],
|
||||||
)
|
)
|
||||||
await _track_mid(context, mid)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Шаг 3: дополнительные вложения/текст ─────────────────────────────────────
|
# ── Шаг 3: дополнительные вложения/текст ─────────────────────────────────────
|
||||||
|
|
||||||
@router.message_created(states=TRANSITIONS["ticket:description_ok"])
|
@router.message_created(states=TicketStates.collecting_attachments)
|
||||||
async def on_ticket_attachment(
|
async def on_ticket_attachment(
|
||||||
event: MessageCreated, context: BaseContext
|
event: MessageCreated, context: BaseContext
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -276,8 +195,8 @@ async def on_ticket_attachment(
|
||||||
# ── Шаг 4: "Готово" → предпросмотр ───────────────────────────────────────────
|
# ── Шаг 4: "Готово" → предпросмотр ───────────────────────────────────────────
|
||||||
|
|
||||||
@router.message_callback(
|
@router.message_callback(
|
||||||
F.callback.payload == PAYLOADS.DONE_TICKET,
|
F.callback.payload == "done_ticket",
|
||||||
states=[TRANSITIONS["ticket:start"], TRANSITIONS["ticket:description_ok"]],
|
states=[TicketStates.waiting_description, TicketStates.collecting_attachments],
|
||||||
)
|
)
|
||||||
async def cb_done_ticket(
|
async def cb_done_ticket(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
|
|
@ -290,12 +209,12 @@ async def cb_done_ticket(
|
||||||
return
|
return
|
||||||
|
|
||||||
if not description:
|
if not description:
|
||||||
await event.answer(notification=TicketTexts.PREVIEW_NEED_TEXT_NOTIF)
|
await event.answer(notification="Сначала опишите проблему!")
|
||||||
await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
text=TicketTexts.PREVIEW_NEED_TEXT,
|
text="❗ Пожалуйста, сначала напишите описание проблемы.",
|
||||||
attachments=[done_ticket_kb()],
|
attachments=[done_ticket_kb()],
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -306,104 +225,75 @@ async def cb_done_ticket(
|
||||||
org = await get_organization(bot_user["org_id"])
|
org = await get_organization(bot_user["org_id"])
|
||||||
org_name = org["name"] if org else None
|
org_name = org["name"] if org else None
|
||||||
|
|
||||||
await context.set_state(TRANSITIONS["ticket:preview"])
|
await context.set_state(TicketStates.confirming)
|
||||||
|
|
||||||
mid = await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
text=_preview_text(org_name, description, pending_atts),
|
text=_preview_text(org_name, description, pending_atts),
|
||||||
attachments=[confirm_ticket_kb()],
|
attachments=[confirm_ticket_kb()],
|
||||||
)
|
)
|
||||||
await _track_mid(context, mid)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Шаг 5б: "Продолжить редактирование" ──────────────────────────────────────
|
# ── Шаг 5б: "Продолжить редактирование" ──────────────────────────────────────
|
||||||
|
|
||||||
@router.message_callback(
|
@router.message_callback(
|
||||||
F.callback.payload == PAYLOADS.EDIT_TICKET,
|
F.callback.payload == "edit_ticket",
|
||||||
states=TRANSITIONS["ticket:preview"],
|
states=TicketStates.confirming,
|
||||||
)
|
)
|
||||||
async def cb_edit_ticket(
|
async def cb_edit_ticket(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
await event.answer(notification="")
|
await event.answer(notification="")
|
||||||
await context.set_state(TRANSITIONS["ticket:edit"])
|
await context.set_state(TicketStates.collecting_attachments)
|
||||||
|
|
||||||
chat_id = event.message.recipient.chat_id if event.message else None
|
chat_id = event.message.recipient.chat_id if event.message else None
|
||||||
if chat_id is None:
|
if chat_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
mid = await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
text=TicketTexts.CONTINUE_EDITING,
|
text=(
|
||||||
|
"✏️ Продолжайте добавлять описание или вложения.\n"
|
||||||
|
"Нажмите «Готово» когда будете готовы."
|
||||||
|
),
|
||||||
attachments=[done_ticket_kb()],
|
attachments=[done_ticket_kb()],
|
||||||
)
|
)
|
||||||
await _track_mid(context, mid)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Шаг 5в: "Отменить создание" ──────────────────────────────────────────────
|
# ── Шаг 5в: "Отменить создание" ──────────────────────────────────────────────
|
||||||
|
|
||||||
@router.message_callback(
|
@router.message_callback(
|
||||||
F.callback.payload == PAYLOADS.CANCEL_TICKET,
|
F.callback.payload == "cancel_ticket",
|
||||||
states=[
|
states=TicketStates.confirming,
|
||||||
TRANSITIONS["ticket:start"],
|
|
||||||
TRANSITIONS["ticket:description_ok"],
|
|
||||||
TRANSITIONS["ticket:preview"],
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
async def cb_cancel_ticket(
|
async def cb_cancel_ticket(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
await event.answer(notification=TicketTexts.CANCEL_NOTIF)
|
await event.answer(notification="Создание отменено")
|
||||||
|
await context.clear()
|
||||||
|
|
||||||
chat_id = event.message.recipient.chat_id if event.message else None
|
chat_id = event.message.recipient.chat_id if event.message else None
|
||||||
if chat_id is None:
|
if chat_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Удаляем все сообщения потока + текущее сообщение с кнопками
|
await bot_send(
|
||||||
callback_mid = event.message.body.mid if (event.message and event.message.body) else None
|
|
||||||
await _delete_flow_messages(event.bot, context, extra_mid=callback_mid)
|
|
||||||
await context.clear()
|
|
||||||
|
|
||||||
loading_mid = await bot_send(
|
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
text=MenuTexts.LOADING,
|
text=await build_main_menu_text(bot_user),
|
||||||
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
|
|
||||||
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а: "Подтвердить" → создание ─────────────────────────────────────────
|
# ── Шаг 5а: "Подтвердить" → создание ─────────────────────────────────────────
|
||||||
|
|
||||||
@router.message_callback(
|
@router.message_callback(
|
||||||
F.callback.payload == PAYLOADS.CONFIRM_TICKET,
|
F.callback.payload == "confirm_ticket",
|
||||||
states=TRANSITIONS["ticket:preview"],
|
states=TicketStates.confirming,
|
||||||
)
|
)
|
||||||
async def cb_confirm_ticket(
|
async def cb_confirm_ticket(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
|
|
@ -411,9 +301,6 @@ async def cb_confirm_ticket(
|
||||||
# Защита от двойного нажатия: читаем данные и сбрасываем состояние атомарно.
|
# Защита от двойного нажатия: читаем данные и сбрасываем состояние атомарно.
|
||||||
# Повторный tap уже не пройдёт фильтр states=confirming.
|
# Повторный tap уже не пройдёт фильтр states=confirming.
|
||||||
data = await context.get_data()
|
data = await context.get_data()
|
||||||
# Удаляем все сообщения потока + текущее (предпросмотр с кнопками)
|
|
||||||
callback_mid = event.message.body.mid if (event.message and event.message.body) else None
|
|
||||||
await _delete_flow_messages(event.bot, context, extra_mid=callback_mid)
|
|
||||||
await context.clear()
|
await context.clear()
|
||||||
|
|
||||||
description = (data.get("description") or "").strip()
|
description = (data.get("description") or "").strip()
|
||||||
|
|
@ -423,16 +310,7 @@ async def cb_confirm_ticket(
|
||||||
if chat_id is None:
|
if chat_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
loading_mid = await bot_send(
|
await event.answer(notification="Отправляем заявку…")
|
||||||
event.bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=bot_user["max_user_id"],
|
|
||||||
text=TicketTexts.SENDING,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Загружаем вложения в Intradesk ────────────────────────────────────────
|
|
||||||
if pending_atts:
|
|
||||||
pending_atts = await upload_attachments(pending_atts, task_id=0, target="Description")
|
|
||||||
|
|
||||||
# ── Данные организации ────────────────────────────────────────────────────
|
# ── Данные организации ────────────────────────────────────────────────────
|
||||||
org = await get_organization(bot_user["org_id"])
|
org = await get_organization(bot_user["org_id"])
|
||||||
|
|
@ -465,13 +343,14 @@ async def cb_confirm_ticket(
|
||||||
|
|
||||||
# ── Отправка в Intradesk (ждём, чтобы получить номер) ────────────────────
|
# ── Отправка в Intradesk (ждём, чтобы получить номер) ────────────────────
|
||||||
task_name = org_name or description[:150]
|
task_name = org_name or description[:150]
|
||||||
|
att_urls = [a["url"] for a in pending_atts if a.get("url")]
|
||||||
|
|
||||||
intradesk_result = await create_intradesk_task(
|
intradesk_result = await create_intradesk_task(
|
||||||
name=task_name,
|
name=task_name,
|
||||||
description=description,
|
description=description,
|
||||||
client_intradesk_id=intradesk_id,
|
client_intradesk_id=intradesk_id,
|
||||||
contact_person_id=contact_person_id,
|
contact_person_id=contact_person_id,
|
||||||
attachments=pending_atts or None,
|
attachment_urls=att_urls or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
intradesk_number: int | None = None
|
intradesk_number: int | None = None
|
||||||
|
|
@ -490,40 +369,30 @@ async def cb_confirm_ticket(
|
||||||
"Intradesk task created: id=%s number=%s for ticket_id=%s",
|
"Intradesk task created: id=%s number=%s for ticket_id=%s",
|
||||||
iid, intradesk_number, ticket_id,
|
iid, intradesk_number, ticket_id,
|
||||||
)
|
)
|
||||||
if intradesk_id:
|
|
||||||
invalidate_client_cache(intradesk_id)
|
|
||||||
|
|
||||||
# ── Итоговое сообщение ────────────────────────────────────────────────────
|
# ── Итоговое сообщение ────────────────────────────────────────────────────
|
||||||
lines = [TicketTexts.CREATED]
|
lines = ["✅ Заявка успешно создана!"]
|
||||||
if intradesk_number:
|
if intradesk_number:
|
||||||
lines.append(TicketTexts.CREATED_NUMBER.format(number=intradesk_number))
|
lines.append(f"Номер заявки: #{intradesk_number}")
|
||||||
lines += ["", f"📄 {task_name}", ""]
|
lines += ["", f"📄 {task_name}", ""]
|
||||||
lines.append(description[:500] + ("…" if len(description) > 500 else ""))
|
lines.append(description[:500] + ("…" if len(description) > 500 else ""))
|
||||||
if pending_atts:
|
if pending_atts:
|
||||||
lines += ["", TicketTexts.PREVIEW_ATTACHMENTS_HEADER]
|
lines += ["", "📎 Вложения:"]
|
||||||
for att in pending_atts:
|
for att in pending_atts:
|
||||||
lines.append(f" • {_att_label(att)}")
|
lines.append(f" • {_att_label(att)}")
|
||||||
lines += ["", TicketTexts.CREATED_CONTACT]
|
lines += ["", "Наши специалисты свяжутся с вами в ближайшее время."]
|
||||||
|
|
||||||
final_text = "\n".join(lines)
|
await bot_send(
|
||||||
edited = False
|
event.bot,
|
||||||
if loading_mid:
|
chat_id=chat_id,
|
||||||
try:
|
user_id=bot_user["max_user_id"],
|
||||||
await event.bot.edit_message(
|
text="\n".join(lines),
|
||||||
message_id=loading_mid,
|
)
|
||||||
text=final_text,
|
|
||||||
attachments=[main_menu_kb()],
|
await bot_send(
|
||||||
)
|
event.bot,
|
||||||
edited = True
|
chat_id=chat_id,
|
||||||
await nav_set(context, loading_mid)
|
user_id=bot_user["max_user_id"],
|
||||||
except Exception:
|
text=await build_main_menu_text(bot_user),
|
||||||
pass
|
attachments=[main_menu_kb()],
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,62 @@
|
||||||
"""Обработчик главного меню."""
|
"""Обработчик главного меню."""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from maxapi.context import BaseContext
|
|
||||||
from maxapi.dispatcher import Router
|
from maxapi.dispatcher import Router
|
||||||
from maxapi.filters import F
|
from maxapi.filters import F
|
||||||
from maxapi.types.updates.message_callback import MessageCallback
|
from maxapi.types.updates.message_callback import MessageCallback
|
||||||
|
from maxapi.context import BaseContext
|
||||||
|
|
||||||
from bot.flow_config import PAYLOADS, TRANSITIONS
|
|
||||||
from bot.keyboards import main_menu_kb
|
from bot.keyboards import main_menu_kb
|
||||||
from bot.nav_helper import nav_finish, nav_prepare
|
from bot.send_helper import bot_reply
|
||||||
from bot.texts import MenuTexts
|
from database.connection import get_db
|
||||||
|
from database.queries.intradesk import (
|
||||||
|
count_active_tasks_for_local_org,
|
||||||
|
count_tasks_for_local_org,
|
||||||
|
)
|
||||||
from database.queries.organizations import get_organization
|
from database.queries.organizations import get_organization
|
||||||
from external.intradesk_api import count_client_open_tasks, get_client_last_comment
|
|
||||||
|
|
||||||
router = Router(router_id="menu")
|
router = Router(router_id="menu")
|
||||||
|
|
||||||
|
|
||||||
async def build_main_menu_text(bot_user: dict) -> str:
|
async def build_main_menu_text(bot_user: dict) -> str:
|
||||||
"""Формирует текст главного меню — данные берутся из Intradesk API."""
|
"""Формирует текст главного меню с именем объекта и статистикой заявок."""
|
||||||
org_id = bot_user.get("org_id")
|
org_id = bot_user.get("org_id")
|
||||||
org_name: str | None = None
|
org_name: str | None = None
|
||||||
intradesk_id: int | None = None
|
|
||||||
|
|
||||||
if org_id:
|
if org_id:
|
||||||
org = await get_organization(org_id)
|
org = await get_organization(org_id)
|
||||||
org_name = org["name"] if org else None
|
org_name = org["name"] if org else None
|
||||||
intradesk_id = org.get("intradesk_id") if org else None
|
|
||||||
|
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
lines.append(MenuTexts.ORG_HEADER.format(org_name=org_name) if org_name else MenuTexts.NO_ORG_HEADER)
|
if org_name:
|
||||||
lines.append(MenuTexts.SEPARATOR)
|
lines.append(f"🏢 {org_name}")
|
||||||
|
|
||||||
if intradesk_id:
|
|
||||||
# Счётчик активных и последний комментарий — параллельно
|
|
||||||
active, last_comment = await asyncio.gather(
|
|
||||||
count_client_open_tasks(intradesk_id),
|
|
||||||
get_client_last_comment(intradesk_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
if active == 0:
|
|
||||||
lines += [
|
|
||||||
MenuTexts.NO_TICKETS,
|
|
||||||
"",
|
|
||||||
MenuTexts.NO_TICKETS_HINT,
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
lines.append(MenuTexts.ACTIVE_COUNT.format(active=active))
|
|
||||||
|
|
||||||
if last_comment and last_comment.get("text"):
|
|
||||||
ctext = last_comment["text"]
|
|
||||||
if len(ctext) > 120:
|
|
||||||
ctext = ctext[:120] + "…"
|
|
||||||
raw_date = last_comment.get("date") or ""
|
|
||||||
date_part = raw_date[:10]
|
|
||||||
time_part = raw_date[11:16] if len(raw_date) >= 16 else ""
|
|
||||||
if len(date_part) == 10:
|
|
||||||
y, m, d = date_part.split("-")
|
|
||||||
short_date = f"{d}.{m}" + (f" {time_part}" if time_part else "")
|
|
||||||
else:
|
|
||||||
short_date = raw_date
|
|
||||||
author = (last_comment.get("author") or "").strip()
|
|
||||||
task_num = last_comment.get("task_number")
|
|
||||||
num_str = f" (заявка #{task_num})" if task_num else ""
|
|
||||||
lines += [
|
|
||||||
"",
|
|
||||||
MenuTexts.LAST_COMMENT.format(num_str=num_str),
|
|
||||||
f"«{ctext}»",
|
|
||||||
MenuTexts.LAST_COMMENT_AUTHOR.format(author=author, short_date=short_date),
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
lines += ["", MenuTexts.NO_NEW_COMMENTS]
|
|
||||||
else:
|
else:
|
||||||
lines.append(MenuTexts.NO_ORG_DATA)
|
lines.append("🏠 Главное меню")
|
||||||
|
|
||||||
lines += ["", MenuTexts.SEPARATOR, MenuTexts.CHOOSE_ACTION]
|
if org_id:
|
||||||
|
db = get_db()
|
||||||
|
total = await count_tasks_for_local_org(db, org_id)
|
||||||
|
active = await count_active_tasks_for_local_org(db, org_id)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
lines.append("Заявок пока нет.")
|
||||||
|
else:
|
||||||
|
lines.append(f"Заявок: {total} (активных: {active})")
|
||||||
|
|
||||||
|
lines.append("\nВыберите действие:")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@router.message_callback(F.callback.payload == PAYLOADS.MAIN_MENU)
|
@router.message_callback(F.callback.payload == "main_menu")
|
||||||
async def cb_main_menu(
|
async def cb_main_menu(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
from bot.handlers.create_ticket import cleanup_ticket_flow
|
await context.set_state(None)
|
||||||
await cleanup_ticket_flow(event.bot, context)
|
|
||||||
await context.set_state(TRANSITIONS["nav:menu"])
|
|
||||||
await event.answer(notification="")
|
await event.answer(notification="")
|
||||||
|
|
||||||
chat_id = event.message.recipient.chat_id if event.message else None
|
|
||||||
|
|
||||||
# Показываем «⏳» — edit-in-place если это последнее навигационное сообщение
|
|
||||||
mid = await nav_prepare(
|
|
||||||
event, context,
|
|
||||||
loading_text=MenuTexts.LOADING,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=bot_user["max_user_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
text = await build_main_menu_text(bot_user)
|
text = await build_main_menu_text(bot_user)
|
||||||
|
await bot_reply(
|
||||||
await nav_finish(
|
event,
|
||||||
event.bot, context,
|
|
||||||
mid=mid,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=bot_user["max_user_id"],
|
|
||||||
text=text,
|
text=text,
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
|
user_id=bot_user["max_user_id"],
|
||||||
|
chat_id=event.message.recipient.chat_id if event.message else None,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
"""
|
|
||||||
Обработчик быстрого ответа на заявку.
|
|
||||||
|
|
||||||
Перехватывает свободные сообщения авторизованных пользователей вне активного
|
|
||||||
флоу и предлагает отправить их как комментарий к последней активной заявке.
|
|
||||||
Последняя активная заявка определяется по:
|
|
||||||
- уведомлению, которое было отправлено пользователю (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,12 +16,10 @@ from maxapi.types.updates.message_created import MessageCreated
|
||||||
from maxapi.context import BaseContext
|
from maxapi.context import BaseContext
|
||||||
from maxapi.filters.command import Command
|
from maxapi.filters.command import Command
|
||||||
|
|
||||||
from bot.flow_config import TRANSITIONS
|
|
||||||
from bot.keyboards import contact_request_kb, main_menu_kb
|
from bot.keyboards import contact_request_kb, main_menu_kb
|
||||||
from bot.logging_setup import get_logger
|
from bot.logging_setup import get_logger
|
||||||
from bot.nav_helper import nav_set
|
|
||||||
from bot.send_helper import bot_send
|
from bot.send_helper import bot_send
|
||||||
from bot.texts import AuthTexts
|
from bot.states import AuthStates
|
||||||
|
|
||||||
logger = get_logger("bot.handlers.start")
|
logger = get_logger("bot.handlers.start")
|
||||||
from database.queries.bot_users import (
|
from database.queries.bot_users import (
|
||||||
|
|
@ -60,40 +58,42 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=event.chat_id,
|
chat_id=event.chat_id,
|
||||||
user_id=user.user_id,
|
user_id=user.user_id,
|
||||||
text=AuthTexts.BLOCKED,
|
text=(
|
||||||
|
"⛔ Ваш номер не найден в системе. Доступ заблокирован.\n"
|
||||||
|
"Обратитесь к администратору для подключения."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if bot_user["is_authorized"]:
|
if bot_user["is_authorized"]:
|
||||||
logger.info("✅ bot_started returning user | user_id=%s", user.user_id)
|
logger.info("✅ bot_started returning user | user_id=%s", user.user_id)
|
||||||
await context.set_state(TRANSITIONS["auth:authorized"])
|
await context.set_state(None)
|
||||||
from bot.handlers.menu import build_main_menu_text
|
await bot_send(
|
||||||
menu_text = AuthTexts.WELCOME_BACK + await build_main_menu_text(bot_user)
|
|
||||||
mid = await bot_send(
|
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=event.chat_id,
|
chat_id=event.chat_id,
|
||||||
user_id=user.user_id,
|
user_id=user.user_id,
|
||||||
text=menu_text,
|
text="👋 С возвращением! Выберите действие:",
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
await nav_set(context, mid)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Новый пользователь — запрашиваем контакт
|
# Новый пользователь — запрашиваем контакт
|
||||||
logger.info("👋 bot_started new user | user_id=%s", user.user_id)
|
logger.info("👋 bot_started new user | user_id=%s", user.user_id)
|
||||||
await context.set_state(TRANSITIONS["auth:new_user"])
|
await context.set_state(AuthStates.waiting_contact)
|
||||||
await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=event.chat_id,
|
chat_id=event.chat_id,
|
||||||
user_id=user.user_id,
|
user_id=user.user_id,
|
||||||
text=AuthTexts.WELCOME_NEW,
|
text=(
|
||||||
|
"👋 Добро пожаловать в систему технической поддержки!\n\n"
|
||||||
|
"Для входа нажмите кнопку ниже и поделитесь своим номером телефона."
|
||||||
|
),
|
||||||
attachments=[contact_request_kb()],
|
attachments=[contact_request_kb()],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message_created(Command("start"))
|
@router.message_created(Command("start"))
|
||||||
async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
|
async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
|
||||||
"""commands_info: Главное меню"""
|
|
||||||
chat_id, user_id = event.get_ids()
|
chat_id, user_id = event.get_ids()
|
||||||
if user_id is None or chat_id is None:
|
if user_id is None or chat_id is None:
|
||||||
return
|
return
|
||||||
|
|
@ -107,12 +107,15 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
|
||||||
username=None,
|
username=None,
|
||||||
first_name=None,
|
first_name=None,
|
||||||
)
|
)
|
||||||
await context.set_state(TRANSITIONS["auth:new_user"])
|
await context.set_state(AuthStates.waiting_contact)
|
||||||
await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=AuthTexts.WELCOME_START,
|
text=(
|
||||||
|
"👋 Добро пожаловать!\n\n"
|
||||||
|
"Поделитесь номером телефона для доступа к системе."
|
||||||
|
),
|
||||||
attachments=[contact_request_kb()],
|
attachments=[contact_request_kb()],
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -122,35 +125,32 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=AuthTexts.BLOCKED_SHORT,
|
text="⛔ Доступ заблокирован. Обратитесь к администратору.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not bot_user["is_authorized"]:
|
if not bot_user["is_authorized"]:
|
||||||
await context.set_state(TRANSITIONS["auth:new_user"])
|
await context.set_state(AuthStates.waiting_contact)
|
||||||
await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=AuthTexts.SHARE_PHONE,
|
text="Поделитесь номером телефона для доступа:",
|
||||||
attachments=[contact_request_kb()],
|
attachments=[contact_request_kb()],
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await context.set_state(TRANSITIONS["auth:authorized"])
|
await context.set_state(None)
|
||||||
from bot.handlers.menu import build_main_menu_text
|
await bot_send(
|
||||||
menu_text = await build_main_menu_text(bot_user)
|
|
||||||
mid = await bot_send(
|
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=menu_text,
|
text="Главное меню:",
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
await nav_set(context, mid)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message_created(ContactFilter(), states=TRANSITIONS["auth:new_user"])
|
@router.message_created(ContactFilter(), states=AuthStates.waiting_contact)
|
||||||
async def on_contact_shared(
|
async def on_contact_shared(
|
||||||
event: MessageCreated,
|
event: MessageCreated,
|
||||||
contact: ContactAttachment,
|
contact: ContactAttachment,
|
||||||
|
|
@ -167,7 +167,7 @@ async def on_contact_shared(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=AuthTexts.CONTACT_READ_ERROR,
|
text="Не удалось прочитать контакт. Попробуйте ещё раз.",
|
||||||
attachments=[contact_request_kb()],
|
attachments=[contact_request_kb()],
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -179,7 +179,7 @@ async def on_contact_shared(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=AuthTexts.CONTACT_NO_PHONE,
|
text="В контакте не найден номер телефона. Попробуйте ещё раз.",
|
||||||
attachments=[contact_request_kb()],
|
attachments=[contact_request_kb()],
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -201,23 +201,18 @@ async def on_contact_shared(
|
||||||
if org_user is None:
|
if org_user is None:
|
||||||
logger.warning("🚫 phone not found, blocking | user_id=%s phone=%s", user_id, phone)
|
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 block_bot_user(max_user_id=user_id, phone=phone)
|
||||||
await context.set_state(TRANSITIONS["auth:blocked"])
|
await context.set_state(None)
|
||||||
await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=AuthTexts.PHONE_NOT_FOUND,
|
text=(
|
||||||
|
"⛔ Ваш номер не найден в системе.\n"
|
||||||
|
"Доступ заблокирован. Обратитесь к администратору для подключения."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return
|
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"])
|
logger.info("✅ authorized | user_id=%s phone=%s org_id=%s", user_id, phone, org_user["org_id"])
|
||||||
await authorize_bot_user(
|
await authorize_bot_user(
|
||||||
|
|
@ -226,89 +221,15 @@ async def on_contact_shared(
|
||||||
org_id=org_user["org_id"],
|
org_id=org_user["org_id"],
|
||||||
first_name=display_name,
|
first_name=display_name,
|
||||||
)
|
)
|
||||||
await context.set_state(TRANSITIONS["auth:contact_found"])
|
await context.set_state(None)
|
||||||
|
|
||||||
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
|
from bot.handlers.menu import build_main_menu_text # local import to avoid circular
|
||||||
menu_text = AuthTexts.AUTHORIZED + await build_main_menu_text(bot_user)
|
menu_text = "✅ Вы успешно авторизованы!\n\n" + await build_main_menu_text(bot_user)
|
||||||
|
|
||||||
# Заменяем заглушку финальным меню
|
await bot_send(
|
||||||
await event.bot.edit_message(
|
event.bot,
|
||||||
message_id=mid,
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
text=menu_text,
|
text=menu_text,
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
await nav_set(context, mid)
|
|
||||||
|
|
||||||
|
|
||||||
# ── /menu ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.message_created(Command("menu"))
|
|
||||||
async def on_menu_command(event: MessageCreated, context: BaseContext) -> None:
|
|
||||||
"""commands_info: Показать меню"""
|
|
||||||
chat_id, user_id = event.get_ids()
|
|
||||||
if user_id is None or chat_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
bot_user = await get_bot_user_by_max_id(user_id)
|
|
||||||
if not bot_user or not bot_user["is_authorized"]:
|
|
||||||
return
|
|
||||||
|
|
||||||
from bot.handlers.create_ticket import cleanup_ticket_flow
|
|
||||||
await cleanup_ticket_flow(event.bot, context)
|
|
||||||
await context.set_state(TRANSITIONS["nav:menu"])
|
|
||||||
|
|
||||||
from bot.handlers.menu import build_main_menu_text
|
|
||||||
mid = await bot_send(
|
|
||||||
event.bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=user_id,
|
|
||||||
text=await build_main_menu_text(bot_user),
|
|
||||||
attachments=[main_menu_kb()],
|
|
||||||
)
|
|
||||||
await nav_set(context, mid)
|
|
||||||
|
|
||||||
|
|
||||||
# ── /cancel ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.message_created(Command("cancel"))
|
|
||||||
async def on_cancel_command(event: MessageCreated, context: BaseContext) -> None:
|
|
||||||
"""commands_info: Отменить текущее действие"""
|
|
||||||
chat_id, user_id = event.get_ids()
|
|
||||||
if user_id is None or chat_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
bot_user = await get_bot_user_by_max_id(user_id)
|
|
||||||
if not bot_user or not bot_user["is_authorized"]:
|
|
||||||
return
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
TicketStates.waiting_description,
|
|
||||||
TicketStates.collecting_attachments,
|
|
||||||
TicketStates.confirming,
|
|
||||||
CommentStates.waiting_text,
|
|
||||||
]
|
|
||||||
|
|
||||||
if state in active_states:
|
|
||||||
await cleanup_ticket_flow(event.bot, context)
|
|
||||||
cancel_text = MenuTexts.CANCEL_PREFIX
|
|
||||||
else:
|
|
||||||
cancel_text = ""
|
|
||||||
|
|
||||||
await context.set_state(TRANSITIONS["nav:cancel"])
|
|
||||||
|
|
||||||
from bot.handlers.menu import build_main_menu_text
|
|
||||||
mid = await bot_send(
|
|
||||||
event.bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=user_id,
|
|
||||||
text=cancel_text + await build_main_menu_text(bot_user),
|
|
||||||
attachments=[main_menu_kb()],
|
|
||||||
)
|
|
||||||
await nav_set(context, mid)
|
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,38 @@
|
||||||
"""
|
"""
|
||||||
Обработчики просмотра заявок (Intradesk API).
|
Обработчики просмотра заявок (Intradesk).
|
||||||
|
|
||||||
Заявки загружаются из Intradesk API по нажатию кнопки — только незакрытые.
|
Навигация через callback payload — все переходы редактируют текущее сообщение:
|
||||||
Навигация через callback payload:
|
|
||||||
"view_tickets" → список, страница 0
|
"view_tickets" → список, страница 0
|
||||||
"itickets_page:{n}" → список, страница n
|
"itickets_page:{n}" → список, страница n
|
||||||
"iticket_detail:{id}" → карточка задачи (из кэша в context)
|
"iticket_detail:{id}" → карточка задачи
|
||||||
"main_menu" → в menu.py
|
"main_menu" → в menu.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
|
|
||||||
from maxapi.context import BaseContext
|
from maxapi.context import BaseContext
|
||||||
from maxapi.dispatcher import Router
|
from maxapi.dispatcher import Router
|
||||||
from maxapi.filters import F
|
from maxapi.filters import F
|
||||||
from maxapi.types.updates.message_callback import MessageCallback
|
from maxapi.types.updates.message_callback import MessageCallback
|
||||||
|
|
||||||
import config
|
|
||||||
from bot.filters import PayloadStartsWith
|
from bot.filters import PayloadStartsWith
|
||||||
from bot.flow_config import PAYLOADS, TRANSITIONS
|
from bot.keyboards import intradesk_tasks_list_kb, iticket_detail_kb, menu_only_kb
|
||||||
from bot.handlers.create_ticket import _extract_attachments
|
from bot.send_helper import bot_reply
|
||||||
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.texts import CommentTexts, TicketDetailTexts, TicketListTexts
|
|
||||||
from database.connection import get_db
|
from database.connection import get_db
|
||||||
from database.queries.intradesk import get_intradesk_contact_id_for_phone
|
from database.queries.intradesk import (
|
||||||
from database.queries.organizations import get_organization
|
count_tasks_for_local_org,
|
||||||
from external.intradesk_api import (
|
get_task_for_local_org,
|
||||||
add_task_comment,
|
get_tasks_for_local_org,
|
||||||
close_task,
|
|
||||||
get_client_open_tasks,
|
|
||||||
get_task_comments,
|
|
||||||
get_task_last_comment,
|
|
||||||
invalidate_client_cache,
|
|
||||||
invalidate_task_comment,
|
|
||||||
upload_attachments,
|
|
||||||
)
|
)
|
||||||
|
from external.intradesk_api import get_task_last_comment
|
||||||
|
|
||||||
router = Router(router_id="view_tickets")
|
router = Router(router_id="view_tickets")
|
||||||
|
|
||||||
_PAGE_SIZE = 5
|
_PAGE_SIZE = 8
|
||||||
|
|
||||||
|
|
||||||
def _status_icon(status_name: str) -> str:
|
def _status_icon(status_name: str) -> str:
|
||||||
sn = status_name.lower()
|
sn = status_name.lower()
|
||||||
if "выполнен" in sn or "закрыт" in sn:
|
if "выполнен" in sn:
|
||||||
return "🟢"
|
return "🟢"
|
||||||
if "работ" in sn:
|
if "работ" in sn:
|
||||||
return "🟡"
|
return "🟡"
|
||||||
|
|
@ -56,176 +41,46 @@ def _status_icon(status_name: str) -> str:
|
||||||
return "⚪"
|
return "⚪"
|
||||||
|
|
||||||
|
|
||||||
def _fmt_date(iso: str) -> str:
|
def _priority_icon(priority_name: str) -> str:
|
||||||
"""'2026-03-26T09:57:11Z' → '26.03.2026 09:57'"""
|
pn = priority_name.lower()
|
||||||
if not iso or len(iso) < 10:
|
if "критич" in pn:
|
||||||
return iso or "—"
|
return "🔴"
|
||||||
y, m, d = iso[:10].split("-")
|
if "высок" in pn:
|
||||||
# iso может быть "2026-03-26T09:57:11Z" или "2026-03-26 09:57"
|
return "🟠"
|
||||||
time_part = iso[11:16] if len(iso) >= 16 else ""
|
if "средн" in pn:
|
||||||
return f"{d}.{m}.{y}" + (f" {time_part}" if time_part else "")
|
return "🟡"
|
||||||
|
return "⚪"
|
||||||
|
|
||||||
|
|
||||||
def _fmt_date_short(iso: str) -> str:
|
async def _tasks_page_text(org_id: int, page: int) -> tuple[str, list[dict], int]:
|
||||||
"""'2026-03-26 11:54' → '26.03 11:54'"""
|
"""Возвращает (текст, задачи, всего)."""
|
||||||
if not iso or len(iso) < 10:
|
db = get_db()
|
||||||
return iso or ""
|
tasks = await get_tasks_for_local_org(db, org_id, page=page, page_size=_PAGE_SIZE)
|
||||||
y, m, d = iso[:10].split("-")
|
total = await count_tasks_for_local_org(db, org_id)
|
||||||
time_part = iso[11:16] if len(iso) >= 16 else ""
|
|
||||||
return f"{d}.{m}" + (f" {time_part}" if time_part else "")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
|
|
||||||
pages = max(1, -(-total // _PAGE_SIZE))
|
|
||||||
lines = [TicketListTexts.HEADER.format(total=total, page=page + 1, pages=pages)]
|
|
||||||
|
|
||||||
for t in tasks:
|
|
||||||
icon = _status_icon(t.get("status_name") or "")
|
|
||||||
num = t.get("task_number") or t.get("id") or "?"
|
|
||||||
name = (t.get("name") or "—")[:60]
|
|
||||||
created = _fmt_date(t.get("created_at") or "")
|
|
||||||
status = t.get("status_name") or "—"
|
|
||||||
|
|
||||||
lines += [
|
|
||||||
"",
|
|
||||||
"─" * 20,
|
|
||||||
f"📌 №{num} · {created}",
|
|
||||||
f"{name}",
|
|
||||||
f"{icon} {status}",
|
|
||||||
]
|
|
||||||
|
|
||||||
comment = t.get("last_comment")
|
|
||||||
if comment and comment.get("text"):
|
|
||||||
ctext = comment["text"][:80] + ("…" if len(comment["text"]) > 80 else "")
|
|
||||||
author_short = (comment.get("author") or "—").split()[0]
|
|
||||||
cdate = _fmt_date_short(comment.get("date") or "")
|
|
||||||
lines.append(f'💬 "{ctext}" — {author_short} ({cdate})')
|
|
||||||
else:
|
|
||||||
lines.append(TicketListTexts.NO_COMMENTS)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
async def _delete_msg(bot, mid: str | None) -> None:
|
|
||||||
"""Тихо удалить сообщение по mid."""
|
|
||||||
if not mid:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await bot.delete_message(message_id=mid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_and_show(
|
|
||||||
event: MessageCallback,
|
|
||||||
bot_user: dict,
|
|
||||||
page: int,
|
|
||||||
context: BaseContext,
|
|
||||||
nav_mid: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Загрузить страницу заявок из API и показать.
|
|
||||||
|
|
||||||
nav_mid — уже отображённый индикатор загрузки (из nav_prepare).
|
|
||||||
Если None — отправляет новое сообщение самостоятельно.
|
|
||||||
Редактирует nav_mid результатом; перед рендером проверяет load_token.
|
|
||||||
"""
|
|
||||||
org_id = bot_user.get("org_id")
|
|
||||||
intradesk_client_id: int | None = None
|
|
||||||
if org_id:
|
|
||||||
org = await get_organization(org_id)
|
|
||||||
intradesk_client_id = org.get("intradesk_id") if org else None
|
|
||||||
|
|
||||||
token = str(time.monotonic_ns())
|
|
||||||
chat_id = event.message.recipient.chat_id if event.message else None
|
|
||||||
|
|
||||||
# Если nav_mid не задан — отправляем loading сами
|
|
||||||
if not nav_mid:
|
|
||||||
nav_mid = await bot_send(
|
|
||||||
event.bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=bot_user["max_user_id"],
|
|
||||||
text=TicketListTexts.LOADING,
|
|
||||||
attachments=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Сохраняем токен и nav_mid (остальные ключи контекста пока не важны)
|
|
||||||
data = await context.get_data()
|
|
||||||
data.update({"load_token": token, "nav_mid": nav_mid})
|
|
||||||
await context.set_data(data)
|
|
||||||
|
|
||||||
if not intradesk_client_id:
|
|
||||||
data = await context.get_data()
|
|
||||||
if data.get("load_token") != token:
|
|
||||||
await _delete_msg(event.bot, nav_mid)
|
|
||||||
return
|
|
||||||
await _edit_or_send(event.bot, nav_mid, chat_id,
|
|
||||||
text=TicketListTexts.NO_ORG,
|
|
||||||
attachments=[menu_only_kb()])
|
|
||||||
return
|
|
||||||
|
|
||||||
# Запрос к API
|
|
||||||
result = await get_client_open_tasks(intradesk_client_id, page=page, page_size=_PAGE_SIZE)
|
|
||||||
tasks = result["tasks"]
|
|
||||||
total = result["total"]
|
|
||||||
|
|
||||||
# Проверяем токен — не ушёл ли пользователь пока грузили
|
|
||||||
data = await context.get_data()
|
|
||||||
if data.get("load_token") != token:
|
|
||||||
await _delete_msg(event.bot, nav_mid)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Сохраняем задачи в контексте для детального просмотра
|
|
||||||
data.update({"tasks": tasks, "page": page, "total": total, "nav_mid": nav_mid})
|
|
||||||
await context.set_data(data)
|
|
||||||
|
|
||||||
if not tasks:
|
if not tasks:
|
||||||
await _edit_or_send(event.bot, nav_mid, chat_id,
|
return "📭 У вашей организации пока нет заявок.", [], 0
|
||||||
text=TicketListTexts.EMPTY,
|
pages = max(1, -(-total // _PAGE_SIZE))
|
||||||
attachments=[menu_only_kb()])
|
text = f"📋 Заявки — стр. {page + 1} из {pages} (всего: {total}):"
|
||||||
return
|
return text, tasks, total
|
||||||
|
|
||||||
from bot.keyboards import intradesk_tasks_list_kb
|
|
||||||
text = _build_tasks_text(tasks, page, total)
|
|
||||||
kb = intradesk_tasks_list_kb(tasks, page=page, total=total, page_size=_PAGE_SIZE)
|
|
||||||
await _edit_or_send(event.bot, nav_mid, chat_id, text=text, attachments=[kb])
|
|
||||||
|
|
||||||
|
|
||||||
async def _edit_or_send(bot, mid: str | None, chat_id: int | None, *, text: str, attachments) -> None:
|
@router.message_callback(F.callback.payload == "view_tickets")
|
||||||
"""Редактировать сообщение по mid или отправить новое если mid недоступен."""
|
|
||||||
if mid:
|
|
||||||
try:
|
|
||||||
await bot.edit_message(
|
|
||||||
message_id=mid,
|
|
||||||
text=text,
|
|
||||||
attachments=attachments if attachments is not None else [],
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if chat_id:
|
|
||||||
await bot.send_message(chat_id=chat_id, text=text, attachments=attachments or [])
|
|
||||||
|
|
||||||
|
|
||||||
@router.message_callback(F.callback.payload == PAYLOADS.VIEW_TICKETS)
|
|
||||||
async def cb_view_tickets(
|
async def cb_view_tickets(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
from bot.handlers.create_ticket import cleanup_ticket_flow
|
|
||||||
await cleanup_ticket_flow(event.bot, context)
|
|
||||||
await event.answer(notification="")
|
await event.answer(notification="")
|
||||||
|
text, tasks, total = await _tasks_page_text(bot_user["org_id"], 0)
|
||||||
chat_id = event.message.recipient.chat_id if event.message else None
|
kb = intradesk_tasks_list_kb(tasks, page=0, total=total) if tasks else menu_only_kb()
|
||||||
nav_mid = await nav_prepare(
|
await bot_reply(
|
||||||
event, context,
|
event,
|
||||||
loading_text=TicketListTexts.LOADING,
|
text=text,
|
||||||
chat_id=chat_id,
|
attachments=[kb],
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
|
chat_id=event.message.recipient.chat_id if event.message else None,
|
||||||
)
|
)
|
||||||
await _load_and_show(event, bot_user, page=0, context=context, nav_mid=nav_mid)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKETS_PAGE))
|
@router.message_callback(PayloadStartsWith("itickets_page:"))
|
||||||
async def cb_itickets_page(
|
async def cb_itickets_page(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -234,311 +89,79 @@ async def cb_itickets_page(
|
||||||
page = int(event.callback.payload.split(":")[1])
|
page = int(event.callback.payload.split(":")[1])
|
||||||
except (IndexError, ValueError):
|
except (IndexError, ValueError):
|
||||||
page = 0
|
page = 0
|
||||||
|
text, tasks, total = await _tasks_page_text(bot_user["org_id"], page)
|
||||||
chat_id = event.message.recipient.chat_id if event.message else None
|
kb = intradesk_tasks_list_kb(tasks, page=page, total=total) if tasks else menu_only_kb()
|
||||||
nav_mid = await nav_prepare(
|
await bot_reply(
|
||||||
event, context,
|
event,
|
||||||
loading_text=TicketListTexts.LOADING,
|
text=text,
|
||||||
chat_id=chat_id,
|
attachments=[kb],
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
|
chat_id=event.message.recipient.chat_id if event.message else None,
|
||||||
)
|
)
|
||||||
await _load_and_show(event, bot_user, page=page, context=context, nav_mid=nav_mid)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message_callback(PayloadStartsWith(PAYLOADS.PFX_ITICKET_DETAIL))
|
@router.message_callback(PayloadStartsWith("iticket_detail:"))
|
||||||
async def cb_iticket_detail(
|
async def cb_iticket_detail(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
task_id = int(event.callback.payload.split(":")[1])
|
task_id = int(event.callback.payload.split(":")[1])
|
||||||
except (IndexError, ValueError):
|
except (IndexError, ValueError):
|
||||||
await event.answer(notification=TicketDetailTexts.GENERIC_ERROR)
|
await event.answer(notification="Ошибка")
|
||||||
|
return
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
task = await get_task_for_local_org(db, task_id, bot_user["org_id"])
|
||||||
|
|
||||||
|
if task is None:
|
||||||
|
await event.answer(notification="Нет доступа")
|
||||||
return
|
return
|
||||||
|
|
||||||
await event.answer(notification="")
|
await event.answer(notification="")
|
||||||
chat_id = event.message.recipient.chat_id if event.message else None
|
|
||||||
|
|
||||||
# Если пришли сюда из комментарийного флоу (кнопка «Назад») —
|
sicon = _status_icon(task.get("status_name") or "")
|
||||||
# удаляем промпт-сообщение и сбрасываем state, сохраняя кэш задач.
|
picon = _priority_icon(task.get("priority_name") or "")
|
||||||
state = await context.get_state()
|
|
||||||
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(TRANSITIONS["comment:done"])
|
|
||||||
# Сохраняем кэш задач, но сбрасываем nav_mid (промпт уже удалён)
|
|
||||||
await context.set_data({
|
|
||||||
"tasks": d.get("tasks"),
|
|
||||||
"page": d.get("page"),
|
|
||||||
"total": d.get("total"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Показываем ⏳ (edit-in-place если меню/список последнее, иначе новое)
|
|
||||||
nav_mid = await nav_prepare(
|
|
||||||
event, context,
|
|
||||||
loading_text=TicketDetailTexts.LOADING,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=bot_user["max_user_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ищем задачу в кэше контекста (загружена при показе списка)
|
|
||||||
data = await context.get_data()
|
|
||||||
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
|
|
||||||
|
|
||||||
if task is None:
|
|
||||||
await nav_finish(
|
|
||||||
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
|
||||||
text=TicketDetailTexts.NOT_FOUND,
|
|
||||||
attachments=[iticket_detail_kb()],
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
icon = _status_icon(task.get("status_name") or "")
|
|
||||||
num = task.get("task_number") or task["id"]
|
num = task.get("task_number") or task["id"]
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"📌 Заявка #{num}",
|
f"📌 Заявка #{num}",
|
||||||
f"Статус: {icon} {task.get('status_name') or '—'}",
|
f"Статус: {sicon} {task.get('status_name') or '—'}",
|
||||||
f"Создана: {_fmt_date(task.get('created_at') or '')}",
|
f"Приоритет: {picon} {task.get('priority_name') or '—'}",
|
||||||
f"Обновлена: {_fmt_date(task.get('updated_at') or '')}",
|
|
||||||
]
|
]
|
||||||
|
if task.get("service_name"):
|
||||||
|
lines.append(f"Сервис: {task['service_name']}")
|
||||||
|
if task.get("executor_name"):
|
||||||
|
lines.append(f"Исполнитель: {task['executor_name']}")
|
||||||
|
lines.append(f"Создана: {(task.get('created_at') or '')[:10] or '—'}")
|
||||||
|
lines.append(f"Обновлена: {(task.get('updated_at') or '')[:10] or '—'}")
|
||||||
|
|
||||||
if task.get("name"):
|
if task.get("name"):
|
||||||
lines += ["", f"📄 {task['name']}"]
|
lines += ["", f"📄 {task['name']}"]
|
||||||
|
|
||||||
if task.get("description"):
|
if task.get("description"):
|
||||||
desc = task["description"]
|
desc = re.sub(r"<[^>]+>", "", task["description"]).strip()
|
||||||
if len(desc) > 600:
|
if desc:
|
||||||
desc = desc[:600] + "…"
|
if len(desc) > 800:
|
||||||
lines += ["", TicketDetailTexts.DESCRIPTION_HEADER, desc]
|
desc = desc[:800] + "…"
|
||||||
|
lines += ["", f"💬 {desc}"]
|
||||||
|
|
||||||
# Последние 10 комментариев из history API
|
# Последний комментарий — запрашиваем из Intradesk API
|
||||||
comments = await get_task_comments(task["id"], count=10)
|
comment = await get_task_last_comment(task["id"])
|
||||||
if comments:
|
if comment and comment.get("text"):
|
||||||
lines += ["", TicketDetailTexts.COMMENTS_HEADER.format(count=len(comments))]
|
ctext = comment["text"]
|
||||||
for c in comments:
|
if len(ctext) > 500:
|
||||||
ctext = c["text"]
|
ctext = ctext[:500] + "…"
|
||||||
if len(ctext) > 300:
|
lines += [
|
||||||
ctext = ctext[:300] + "…"
|
"",
|
||||||
lines += [
|
f"💭 Последний комментарий ({comment['date']}):",
|
||||||
"",
|
f" {ctext}",
|
||||||
"─" * 16,
|
f" — {comment['author']}",
|
||||||
f"{c['date']} — {c['author']}",
|
]
|
||||||
ctext,
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
lines += ["", TicketDetailTexts.NO_COMMENTS]
|
|
||||||
|
|
||||||
await nav_finish(
|
await bot_reply(
|
||||||
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
event,
|
||||||
text="\n".join(lines),
|
text="\n".join(lines),
|
||||||
attachments=[iticket_detail_kb(task_id=task["id"], can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
|
attachments=[iticket_detail_kb()],
|
||||||
)
|
|
||||||
|
|
||||||
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(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=TicketDetailTexts.GENERIC_ERROR)
|
|
||||||
return
|
|
||||||
|
|
||||||
await event.answer(notification="")
|
|
||||||
|
|
||||||
# Ищем задачу в кэше
|
|
||||||
data = await context.get_data()
|
|
||||||
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
|
|
||||||
|
|
||||||
chat_id = event.message.recipient.chat_id if event.message else None
|
|
||||||
|
|
||||||
if not task_number:
|
|
||||||
nav_mid = await nav_prepare(
|
|
||||||
event, context,
|
|
||||||
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=TicketDetailTexts.CLOSE_NUMBER_ERROR,
|
|
||||||
attachments=[iticket_detail_kb()],
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
nav_mid = await nav_prepare(
|
|
||||||
event, context,
|
|
||||||
loading_text=TicketDetailTexts.CLOSING,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
|
chat_id=event.message.recipient.chat_id if event.message else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Получаем 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"]
|
|
||||||
)
|
|
||||||
|
|
||||||
ok = await close_task(task_number=task_number, contact_person_id=contact_person_id)
|
|
||||||
|
|
||||||
if ok:
|
|
||||||
# Сбрасываем кэш — задача теперь закрыта
|
|
||||||
invalidate_task_comment(task_id)
|
|
||||||
if intradesk_id:
|
|
||||||
invalidate_client_cache(intradesk_id)
|
|
||||||
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=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=TicketDetailTexts.CLOSE_ERROR,
|
|
||||||
attachments=[iticket_detail_kb(task_id=task_id, can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Оставить комментарий ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@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=TicketDetailTexts.GENERIC_ERROR)
|
|
||||||
return
|
|
||||||
|
|
||||||
await event.answer(notification="")
|
|
||||||
|
|
||||||
# Ищем task_number в кэше контекста; сохраняем кэш задач для кнопки «Назад»
|
|
||||||
data = await context.get_data()
|
|
||||||
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(TRANSITIONS["comment:start"])
|
|
||||||
await context.set_data({
|
|
||||||
"comment_task_id": task_id,
|
|
||||||
"comment_task_number": task_number,
|
|
||||||
"flow_message_ids": [],
|
|
||||||
# Сохраняем кэш списка чтобы кнопка «Назад» нашла задачу
|
|
||||||
"tasks": data.get("tasks"),
|
|
||||||
"page": data.get("page"),
|
|
||||||
"total": data.get("total"),
|
|
||||||
})
|
|
||||||
|
|
||||||
chat_id = event.message.recipient.chat_id if event.message else None
|
|
||||||
if chat_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
num_str = f" к заявке #{task_number}" if task_number else ""
|
|
||||||
mid = await bot_send(
|
|
||||||
event.bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=bot_user["max_user_id"],
|
|
||||||
text=CommentTexts.PROMPT.format(num_str=num_str),
|
|
||||||
attachments=[comment_input_kb(task_id)],
|
|
||||||
)
|
|
||||||
# Сохраняем mid для возможной очистки
|
|
||||||
if mid:
|
|
||||||
d = await context.get_data()
|
|
||||||
d["flow_message_ids"] = [mid]
|
|
||||||
await context.set_data(d)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message_created(states=TRANSITIONS["comment:start"])
|
|
||||||
async def on_comment_text(
|
|
||||||
event, context: BaseContext, bot_user: dict
|
|
||||||
) -> None:
|
|
||||||
chat_id, user_id = event.get_ids()
|
|
||||||
if chat_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
body = event.message.body
|
|
||||||
text = (body.text or "").strip() if body else ""
|
|
||||||
file_atts = _extract_attachments(event)
|
|
||||||
|
|
||||||
if not text and not file_atts:
|
|
||||||
await bot_send(
|
|
||||||
event.bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=user_id,
|
|
||||||
text=CommentTexts.EMPTY_ERROR,
|
|
||||||
attachments=[menu_only_kb()],
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
data = await context.get_data()
|
|
||||||
task_id = data.get("comment_task_id")
|
|
||||||
task_number = data.get("comment_task_number")
|
|
||||||
|
|
||||||
# Сбрасываем состояние до отправки API — защита от повторов
|
|
||||||
await context.clear()
|
|
||||||
|
|
||||||
# Получаем данные организации (нужны и для 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"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Загружаем файлы в Intradesk (target=Comment, task_id=Intradesk внутренний ID)
|
|
||||||
if file_atts:
|
|
||||||
file_atts = await upload_attachments(
|
|
||||||
file_atts, task_id=task_id or 0, target="Comment"
|
|
||||||
)
|
|
||||||
|
|
||||||
comment_text = text or CommentTexts.FILE_ONLY
|
|
||||||
|
|
||||||
ok = await add_task_comment(
|
|
||||||
task_number=task_number,
|
|
||||||
text=comment_text,
|
|
||||||
contact_person_id=contact_person_id,
|
|
||||||
attachments=file_atts or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if ok:
|
|
||||||
# Сбрасываем кэш — следующий переход в меню/список покажет свежие данные
|
|
||||||
if task_id:
|
|
||||||
invalidate_task_comment(task_id)
|
|
||||||
if intradesk_id:
|
|
||||||
invalidate_client_cache(intradesk_id)
|
|
||||||
|
|
||||||
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=result_text,
|
|
||||||
attachments=[main_menu_kb()],
|
|
||||||
)
|
|
||||||
await nav_set(context, menu_mid)
|
|
||||||
|
|
|
||||||
115
bot/keyboards.py
115
bot/keyboards.py
|
|
@ -5,17 +5,12 @@ from maxapi.types.attachments.buttons import (
|
||||||
)
|
)
|
||||||
from maxapi.utils.inline_keyboard import InlineKeyboardBuilder
|
from maxapi.utils.inline_keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
from bot.flow_config import PAYLOADS
|
|
||||||
from bot.texts import Btn
|
|
||||||
|
|
||||||
|
|
||||||
def main_menu_kb() -> Attachment:
|
def main_menu_kb() -> Attachment:
|
||||||
return (
|
return (
|
||||||
InlineKeyboardBuilder()
|
InlineKeyboardBuilder()
|
||||||
.row(
|
.row(CallbackButton(text="📝 Создать заявку", payload="create_ticket"))
|
||||||
CallbackButton(text=Btn.CREATE_TICKET, payload=PAYLOADS.CREATE_TICKET),
|
.row(CallbackButton(text="📋 Мои заявки", payload="view_tickets"))
|
||||||
CallbackButton(text=Btn.VIEW_TICKETS, payload=PAYLOADS.VIEW_TICKETS),
|
|
||||||
)
|
|
||||||
.as_markup()
|
.as_markup()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -23,7 +18,7 @@ def main_menu_kb() -> Attachment:
|
||||||
def contact_request_kb() -> Attachment:
|
def contact_request_kb() -> Attachment:
|
||||||
return (
|
return (
|
||||||
InlineKeyboardBuilder()
|
InlineKeyboardBuilder()
|
||||||
.row(RequestContactButton(text=Btn.SHARE_CONTACT))
|
.row(RequestContactButton(text="📱 Поделиться контактом"))
|
||||||
.as_markup()
|
.as_markup()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -32,11 +27,9 @@ def confirm_ticket_kb() -> Attachment:
|
||||||
"""Клавиатура подтверждения создания заявки."""
|
"""Клавиатура подтверждения создания заявки."""
|
||||||
return (
|
return (
|
||||||
InlineKeyboardBuilder()
|
InlineKeyboardBuilder()
|
||||||
.row(CallbackButton(text=Btn.CONFIRM_TICKET, payload=PAYLOADS.CONFIRM_TICKET))
|
.row(CallbackButton(text="✅ Подтвердить", payload="confirm_ticket"))
|
||||||
.row(
|
.row(CallbackButton(text="✏️ Продолжить редактирование", payload="edit_ticket"))
|
||||||
CallbackButton(text=Btn.EDIT_TICKET, payload=PAYLOADS.EDIT_TICKET),
|
.row(CallbackButton(text="❌ Отменить создание", payload="cancel_ticket"))
|
||||||
CallbackButton(text=Btn.CANCEL_TICKET, payload=PAYLOADS.CANCEL_TICKET),
|
|
||||||
)
|
|
||||||
.as_markup()
|
.as_markup()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -45,7 +38,7 @@ def menu_only_kb() -> Attachment:
|
||||||
"""Клавиатура с единственной кнопкой возврата в главное меню."""
|
"""Клавиатура с единственной кнопкой возврата в главное меню."""
|
||||||
return (
|
return (
|
||||||
InlineKeyboardBuilder()
|
InlineKeyboardBuilder()
|
||||||
.row(CallbackButton(text=Btn.MAIN_MENU, payload=PAYLOADS.MAIN_MENU))
|
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||||
.as_markup()
|
.as_markup()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -53,10 +46,7 @@ def menu_only_kb() -> Attachment:
|
||||||
def done_ticket_kb() -> Attachment:
|
def done_ticket_kb() -> Attachment:
|
||||||
return (
|
return (
|
||||||
InlineKeyboardBuilder()
|
InlineKeyboardBuilder()
|
||||||
.row(
|
.row(CallbackButton(text="✅ Готово", payload="done_ticket"))
|
||||||
CallbackButton(text=Btn.DONE_TICKET, payload=PAYLOADS.DONE_TICKET),
|
|
||||||
CallbackButton(text=Btn.CANCEL_TICKET, payload=PAYLOADS.CANCEL_TICKET),
|
|
||||||
)
|
|
||||||
.as_markup()
|
.as_markup()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,19 +65,19 @@ def tickets_list_kb(
|
||||||
builder.row(
|
builder.row(
|
||||||
CallbackButton(
|
CallbackButton(
|
||||||
text=f"#{t['id']} {status_icon} {desc_preview}",
|
text=f"#{t['id']} {status_icon} {desc_preview}",
|
||||||
payload=PAYLOADS.TICKET_DETAIL.format(id=t["id"]),
|
payload=f"ticket_detail:{t['id']}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
nav = []
|
nav = []
|
||||||
if page > 0:
|
if page > 0:
|
||||||
nav.append(CallbackButton(text=Btn.PREV_PAGE, payload=PAYLOADS.TICKETS_PAGE.format(page=page - 1)))
|
nav.append(CallbackButton(text="← Назад", payload=f"tickets_page:{page - 1}"))
|
||||||
if (page + 1) * page_size < total:
|
if (page + 1) * page_size < total:
|
||||||
nav.append(CallbackButton(text=Btn.NEXT_PAGE, payload=PAYLOADS.TICKETS_PAGE.format(page=page + 1)))
|
nav.append(CallbackButton(text="Вперёд →", payload=f"tickets_page:{page + 1}"))
|
||||||
if nav:
|
if nav:
|
||||||
builder.row(*nav)
|
builder.row(*nav)
|
||||||
|
|
||||||
builder.row(CallbackButton(text=Btn.MAIN_MENU, payload=PAYLOADS.MAIN_MENU))
|
builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,91 +102,28 @@ def intradesk_tasks_list_kb(
|
||||||
builder.row(
|
builder.row(
|
||||||
CallbackButton(
|
CallbackButton(
|
||||||
text=f"#{label} {icon} {name_preview}",
|
text=f"#{label} {icon} {name_preview}",
|
||||||
payload=PAYLOADS.ITICKET_DETAIL.format(id=t["id"]),
|
payload=f"iticket_detail:{t['id']}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
nav = []
|
nav = []
|
||||||
if page > 0:
|
if page > 0:
|
||||||
nav.append(CallbackButton(text=Btn.PREV_PAGE, payload=PAYLOADS.ITICKETS_PAGE.format(page=page - 1)))
|
nav.append(CallbackButton(text="← Пред.", payload=f"itickets_page:{page - 1}"))
|
||||||
if (page + 1) * page_size < total:
|
if (page + 1) * page_size < total:
|
||||||
nav.append(CallbackButton(text=Btn.NEXT_PAGE, payload=PAYLOADS.ITICKETS_PAGE.format(page=page + 1)))
|
nav.append(CallbackButton(text="След. →", payload=f"itickets_page:{page + 1}"))
|
||||||
if nav:
|
if nav:
|
||||||
builder.row(*nav)
|
builder.row(*nav)
|
||||||
|
|
||||||
builder.row(CallbackButton(text=Btn.BACK_TO_MENU, payload=PAYLOADS.MAIN_MENU))
|
builder.row(CallbackButton(text="← Назад в меню", payload="main_menu"))
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def iticket_detail_kb(task_id: int | None = None, can_close: bool = False) -> Attachment:
|
def iticket_detail_kb() -> Attachment:
|
||||||
"""Клавиатура внутри карточки Intradesk-задачи."""
|
"""Клавиатура внутри карточки Intradesk-задачи."""
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
action_row = []
|
|
||||||
if task_id is not None:
|
|
||||||
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:
|
|
||||||
action_row.append(CallbackButton(
|
|
||||||
text=Btn.CLOSE_TICKET,
|
|
||||||
payload=PAYLOADS.ITICKET_CLOSE.format(task_id=task_id),
|
|
||||||
))
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def comment_input_kb(task_id: int) -> Attachment:
|
|
||||||
"""Клавиатура при вводе комментария — кнопка назад и меню."""
|
|
||||||
return (
|
return (
|
||||||
InlineKeyboardBuilder()
|
InlineKeyboardBuilder()
|
||||||
.row(
|
.row(CallbackButton(text="← К списку заявок", payload="itickets_page:0"))
|
||||||
CallbackButton(
|
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||||
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(task_id: int, task_number: int | None = None) -> Attachment:
|
|
||||||
"""Клавиатура под уведомлением о новом комментарии."""
|
|
||||||
return (
|
|
||||||
InlineKeyboardBuilder()
|
|
||||||
.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()
|
.as_markup()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -204,7 +131,7 @@ def reply_confirm_kb(task_id: int) -> Attachment:
|
||||||
def ticket_detail_kb(page: int = 0) -> Attachment:
|
def ticket_detail_kb(page: int = 0) -> Attachment:
|
||||||
return (
|
return (
|
||||||
InlineKeyboardBuilder()
|
InlineKeyboardBuilder()
|
||||||
.row(CallbackButton(text=Btn.BACK_TO_TICKETS, payload=PAYLOADS.TICKETS_PAGE.format(page=page)))
|
.row(CallbackButton(text="← К списку", payload=f"tickets_page:{page}"))
|
||||||
.row(CallbackButton(text=Btn.MAIN_MENU, payload=PAYLOADS.MAIN_MENU))
|
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||||
.as_markup()
|
.as_markup()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"""
|
|
||||||
Утилиты навигационных переходов.
|
|
||||||
|
|
||||||
Логика:
|
|
||||||
- Если кнопка нажата на последнем навигационном сообщении (nav_mid в контексте),
|
|
||||||
редактируем его на месте (edit-in-place).
|
|
||||||
- Иначе удаляем триггерное сообщение и отправляем новое в конец чата.
|
|
||||||
|
|
||||||
nav_mid сохраняется в контексте и обновляется при каждом nav_finish.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from maxapi.context import BaseContext
|
|
||||||
from maxapi.types.updates.message_callback import MessageCallback
|
|
||||||
|
|
||||||
from bot.send_helper import bot_send
|
|
||||||
|
|
||||||
|
|
||||||
async def nav_prepare(
|
|
||||||
event: MessageCallback,
|
|
||||||
context: BaseContext,
|
|
||||||
loading_text: str,
|
|
||||||
chat_id: int | None,
|
|
||||||
user_id: int,
|
|
||||||
) -> str | None:
|
|
||||||
"""
|
|
||||||
Начать навигационный переход: показать индикатор загрузки.
|
|
||||||
|
|
||||||
Возвращает mid, который нужно передать в nav_finish.
|
|
||||||
"""
|
|
||||||
trigger_mid = event.message.body.mid if (event.message and event.message.body) else None
|
|
||||||
data = await context.get_data()
|
|
||||||
nav_mid = data.get("nav_mid")
|
|
||||||
|
|
||||||
if trigger_mid and trigger_mid == nav_mid:
|
|
||||||
# Это последнее навигационное сообщение — редактируем на месте
|
|
||||||
try:
|
|
||||||
await event.bot.edit_message(
|
|
||||||
message_id=trigger_mid,
|
|
||||||
text=loading_text,
|
|
||||||
attachments=[],
|
|
||||||
)
|
|
||||||
return trigger_mid
|
|
||||||
except Exception:
|
|
||||||
pass # fallthrough: отправим новое
|
|
||||||
|
|
||||||
# Старое сообщение (или nav_mid не совпадает) → удаляем и отправляем новое
|
|
||||||
if trigger_mid:
|
|
||||||
try:
|
|
||||||
await event.bot.delete_message(message_id=trigger_mid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return await bot_send(
|
|
||||||
event.bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=user_id,
|
|
||||||
text=loading_text,
|
|
||||||
attachments=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def nav_finish(
|
|
||||||
bot,
|
|
||||||
context: BaseContext,
|
|
||||||
mid: str | None,
|
|
||||||
chat_id: int | None,
|
|
||||||
user_id: int,
|
|
||||||
text: str,
|
|
||||||
attachments: list,
|
|
||||||
) -> str | None:
|
|
||||||
"""
|
|
||||||
Завершить навигационный переход: вставить финальный контент в mid.
|
|
||||||
При неудаче редактирования — отправляет новое сообщение.
|
|
||||||
Всегда обновляет nav_mid в контексте.
|
|
||||||
"""
|
|
||||||
result_mid: str | None = None
|
|
||||||
|
|
||||||
if mid:
|
|
||||||
try:
|
|
||||||
await bot.edit_message(
|
|
||||||
message_id=mid,
|
|
||||||
text=text,
|
|
||||||
attachments=attachments or [],
|
|
||||||
)
|
|
||||||
result_mid = mid
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not result_mid:
|
|
||||||
result_mid = await bot_send(
|
|
||||||
bot,
|
|
||||||
chat_id=chat_id,
|
|
||||||
user_id=user_id,
|
|
||||||
text=text,
|
|
||||||
attachments=attachments,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем nav_mid, сохраняя остальные данные контекста
|
|
||||||
data = await context.get_data()
|
|
||||||
data["nav_mid"] = result_mid
|
|
||||||
await context.set_data(data)
|
|
||||||
return result_mid
|
|
||||||
|
|
||||||
|
|
||||||
async def nav_set(context: BaseContext, mid: str | None) -> None:
|
|
||||||
"""Сохранить nav_mid после ручной отправки навигационного сообщения."""
|
|
||||||
data = await context.get_data()
|
|
||||||
data["nav_mid"] = mid
|
|
||||||
await context.set_data(data)
|
|
||||||
184
bot/notifier.py
184
bot/notifier.py
|
|
@ -1,184 +0,0 @@
|
||||||
"""
|
|
||||||
Фоновый опрос Intradesk API для push-уведомлений о новых комментариях.
|
|
||||||
|
|
||||||
Алгоритм за один цикл:
|
|
||||||
1. Один запрос — все открытые задачи по всем клиентам сразу.
|
|
||||||
2. Группируем по clientid в Python — никакого цикла запросов по клиентам.
|
|
||||||
3. Для задач с изменившимся updatedat — запрашиваем историю (1 запрос на задачу).
|
|
||||||
4. Нашли новый комментарий → уведомляем всех пользователей организации.
|
|
||||||
5. Задачи, исчезнувшие из открытых — убираем из слежения.
|
|
||||||
|
|
||||||
Первая встреча с задачей: запоминаем updatedat как базовую линию, историю не смотрим.
|
|
||||||
Первое изменение updatedat: устанавливаем comment-baseline, не уведомляем.
|
|
||||||
Последующие изменения: проверяем и уведомляем.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
import config
|
|
||||||
from database.queries.bot_users import get_all_bot_users
|
|
||||||
from database.queries.task_watch import (
|
|
||||||
get_task_watch,
|
|
||||||
get_watched_clients,
|
|
||||||
remove_tasks_not_in,
|
|
||||||
upsert_task_watch,
|
|
||||||
)
|
|
||||||
from external.intradesk_api import (
|
|
||||||
fetch_all_open_tasks_lightweight,
|
|
||||||
get_task_comments,
|
|
||||||
invalidate_task_comment,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
ctext = ctext[:200] + "…"
|
|
||||||
|
|
||||||
text = (
|
|
||||||
f"💬 Новый ответ по заявке #{task_number}\n\n"
|
|
||||||
f"— {comment['author']}, {comment['date']}"
|
|
||||||
f"«{ctext}»\n"
|
|
||||||
|
|
||||||
)
|
|
||||||
await bot.send_message(
|
|
||||||
chat_id=chat_id,
|
|
||||||
text=text,
|
|
||||||
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:
|
|
||||||
"""Проверить одну задачу и при необходимости отправить уведомление."""
|
|
||||||
tid = task["id"]
|
|
||||||
task_number = task["task_number"]
|
|
||||||
client_id = task["client_id"]
|
|
||||||
updatedat = task["updatedat"]
|
|
||||||
|
|
||||||
watch = await get_task_watch(tid)
|
|
||||||
|
|
||||||
if watch is None:
|
|
||||||
# Новая задача — запоминаем updatedat, history не трогаем
|
|
||||||
await upsert_task_watch(tid, client_id, task_number, updatedat, "")
|
|
||||||
logger.debug("task_watch new: task=%s client=%s", tid, client_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
if updatedat == watch["last_updatedat"]:
|
|
||||||
return # Ничего не изменилось — history не трогаем
|
|
||||||
|
|
||||||
# updatedat изменился — только теперь идём в history API
|
|
||||||
last_known = watch["last_comment_date"]
|
|
||||||
|
|
||||||
invalidate_task_comment(tid)
|
|
||||||
comments = await get_task_comments(tid, count=10)
|
|
||||||
|
|
||||||
if not last_known:
|
|
||||||
# Первое изменение с момента начала слежения — устанавливаем baseline
|
|
||||||
baseline = comments[0]["date"] if comments else ""
|
|
||||||
await upsert_task_watch(tid, client_id, task_number, updatedat, baseline)
|
|
||||||
return
|
|
||||||
|
|
||||||
new_comments = [c for c in comments if c["date"] > last_known]
|
|
||||||
|
|
||||||
if new_comments:
|
|
||||||
latest = new_comments[0]
|
|
||||||
users = await get_all_bot_users(is_authorized=1, is_blocked=0, org_id=org_id)
|
|
||||||
notified = 0
|
|
||||||
for user in users:
|
|
||||||
chat_id = user.get("max_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, max_user_id, tid, task_number, latest)
|
|
||||||
notified += 1
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"notify failed user=%s task=%s: %s",
|
|
||||||
max_user_id, task_number, exc,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"notifier: task=%s (#%s) new comment → notified %d users",
|
|
||||||
tid, task_number, notified,
|
|
||||||
)
|
|
||||||
await upsert_task_watch(tid, client_id, task_number, updatedat, latest["date"])
|
|
||||||
else:
|
|
||||||
# updatedat изменился (смена статуса и т.п.), новых комментариев нет
|
|
||||||
await upsert_task_watch(tid, client_id, task_number, updatedat, last_known)
|
|
||||||
|
|
||||||
|
|
||||||
async def _poll_once(bot) -> None:
|
|
||||||
# Один запрос — все открытые задачи
|
|
||||||
all_tasks = await fetch_all_open_tasks_lightweight()
|
|
||||||
|
|
||||||
# Группируем по clientid прямо в Python
|
|
||||||
tasks_by_client: dict[int, list[dict]] = defaultdict(list)
|
|
||||||
for task in all_tasks:
|
|
||||||
tasks_by_client[task["client_id"]].append(task)
|
|
||||||
|
|
||||||
# Маппинг intradesk_id → org_id из нашей БД
|
|
||||||
clients = await get_watched_clients()
|
|
||||||
client_to_org = {c["intradesk_id"]: c["org_id"] for c in clients}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"notifier poll: %d tasks across %d clients",
|
|
||||||
len(all_tasks), len(tasks_by_client),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обрабатываем задачи по клиентам
|
|
||||||
for client_id, tasks in tasks_by_client.items():
|
|
||||||
org_id = client_to_org.get(client_id)
|
|
||||||
if org_id is None:
|
|
||||||
continue # клиент есть в Intradesk, но не привязан к нашей БД
|
|
||||||
|
|
||||||
task_ids_seen = {t["id"] for t in tasks}
|
|
||||||
|
|
||||||
for task in tasks:
|
|
||||||
try:
|
|
||||||
await _process_task(task, org_id, bot)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(
|
|
||||||
"notifier error task=%s client=%s: %s", task["id"], client_id, exc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Убираем из слежения задачи, пропавшие из открытых
|
|
||||||
await remove_tasks_not_in(client_id, task_ids_seen)
|
|
||||||
|
|
||||||
# Клиенты из нашей БД, у которых сейчас 0 открытых задач
|
|
||||||
for c in clients:
|
|
||||||
if c["intradesk_id"] not in tasks_by_client:
|
|
||||||
await remove_tasks_not_in(c["intradesk_id"], set())
|
|
||||||
|
|
||||||
|
|
||||||
async def run_notifier(bot) -> None:
|
|
||||||
"""Запустить фоновый опрос. Добавлен в asyncio.gather в main.py."""
|
|
||||||
interval = config.NOTIFICATION_POLL_INTERVAL
|
|
||||||
if not interval or interval <= 0:
|
|
||||||
logger.info("Notifier disabled (NOTIFICATION_POLL_INTERVAL=0)")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Notifier started, interval=%ds", interval)
|
|
||||||
# Пауза перед первым прогоном — даём боту и БД время инициализироваться
|
|
||||||
await asyncio.sleep(30)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await _poll_once(bot)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("notifier _poll_once failed: %s", exc)
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
|
|
@ -9,23 +9,19 @@ async def bot_send(
|
||||||
user_id: int | None,
|
user_id: int | None,
|
||||||
text: str,
|
text: str,
|
||||||
attachments=None,
|
attachments=None,
|
||||||
) -> str | None:
|
) -> None:
|
||||||
"""Отправить новое сообщение. Возвращает message_id (mid) или None."""
|
"""Отправить новое сообщение."""
|
||||||
kw: dict = {"chat_id": chat_id, "text": text}
|
kw: dict = {"chat_id": chat_id, "text": text}
|
||||||
if attachments is not None:
|
if attachments is not None:
|
||||||
kw["attachments"] = attachments
|
kw["attachments"] = attachments
|
||||||
result = await bot.send_message(**kw)
|
await bot.send_message(**kw)
|
||||||
mid: str | None = None
|
|
||||||
if result and result.message and result.message.body:
|
|
||||||
mid = result.message.body.mid
|
|
||||||
await log_message(
|
await log_message(
|
||||||
direction="out",
|
direction="out",
|
||||||
max_user_id=user_id,
|
max_user_id=user_id,
|
||||||
max_chat_id=chat_id,
|
max_chat_id=chat_id,
|
||||||
message_id=mid,
|
message_id=None,
|
||||||
text=text,
|
text=text,
|
||||||
)
|
)
|
||||||
return mid
|
|
||||||
|
|
||||||
|
|
||||||
async def bot_edit(
|
async def bot_edit(
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,11 @@ from maxapi.dispatcher import Dispatcher, Router
|
||||||
import config
|
import config
|
||||||
from bot.bot_instance import set_bot
|
from bot.bot_instance import set_bot
|
||||||
from bot.middleware import AuthMiddleware
|
from bot.middleware import AuthMiddleware
|
||||||
from bot.handlers import start, menu, create_ticket, view_tickets, quick_reply
|
from bot.handlers import start, menu, create_ticket, view_tickets
|
||||||
|
|
||||||
|
|
||||||
def create_bot_and_dispatcher() -> tuple[Bot, Dispatcher]:
|
def create_bot_and_dispatcher() -> tuple[Bot, Dispatcher]:
|
||||||
bot = Bot(token=config.BOT_TOKEN)
|
bot = Bot(token=config.BOT_TOKEN)
|
||||||
bot.headers["Accept-Encoding"] = "gzip, deflate"
|
|
||||||
set_bot(bot)
|
set_bot(bot)
|
||||||
|
|
||||||
dp = Dispatcher(router_id="main", storage=MemoryContext)
|
dp = Dispatcher(router_id="main", storage=MemoryContext)
|
||||||
|
|
@ -22,7 +21,6 @@ def create_bot_and_dispatcher() -> tuple[Bot, Dispatcher]:
|
||||||
menu.router,
|
menu.router,
|
||||||
create_ticket.router,
|
create_ticket.router,
|
||||||
view_tickets.router,
|
view_tickets.router,
|
||||||
quick_reply.router, # последним — catch-all для свободных сообщений
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return bot, dp
|
return bot, dp
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,3 @@ class TicketStates(StatesGroup):
|
||||||
waiting_description = State() # Ожидаем первое сообщение с описанием
|
waiting_description = State() # Ожидаем первое сообщение с описанием
|
||||||
collecting_attachments = State() # Накапливаем доп. файлы/текст
|
collecting_attachments = State() # Накапливаем доп. файлы/текст
|
||||||
confirming = State() # Предпросмотр — ждём подтверждения
|
confirming = State() # Предпросмотр — ждём подтверждения
|
||||||
pending_confirm = State() # Быстрый ответ — ждём подтверждения отправки
|
|
||||||
|
|
||||||
|
|
||||||
class CommentStates(StatesGroup):
|
|
||||||
waiting_text = State() # Ожидаем текст комментария к заявке
|
|
||||||
|
|
|
||||||
193
bot/texts.py
193
bot/texts.py
|
|
@ -1,193 +0,0 @@
|
||||||
"""
|
|
||||||
Все пользовательские тексты и подписи кнопок.
|
|
||||||
|
|
||||||
Шаблоны с плейсхолдерами форматируются на месте вызова через .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 = "📋 Выбрать другую заявку"
|
|
||||||
|
|
@ -38,10 +38,6 @@ INTRADESK_CHANGES_API_KEY: str = os.environ.get("INTRADESK_CHANGES_API_KEY", "")
|
||||||
INTRADESK_DEFAULT_SERVICE: str = os.environ.get("INTRADESK_DEFAULT_SERVICE", "")
|
INTRADESK_DEFAULT_SERVICE: str = os.environ.get("INTRADESK_DEFAULT_SERVICE", "")
|
||||||
# ID типа заявки по умолчанию (Настройки → Заявки → Типы заявок → ID)
|
# ID типа заявки по умолчанию (Настройки → Заявки → Типы заявок → ID)
|
||||||
INTRADESK_DEFAULT_TASK_TYPE: str = os.environ.get("INTRADESK_DEFAULT_TASK_TYPE", "")
|
INTRADESK_DEFAULT_TASK_TYPE: str = os.environ.get("INTRADESK_DEFAULT_TASK_TYPE", "")
|
||||||
# ID статуса «Выполнена» для кнопки «Закрыть заявку» (Настройки → Справочники → Статусы → ID)
|
|
||||||
INTRADESK_CLOSE_STATUS_ID: str = os.environ.get("INTRADESK_CLOSE_STATUS_ID", "")
|
|
||||||
# Интервал опроса Intradesk для push-уведомлений, секунды (0 — отключить)
|
|
||||||
NOTIFICATION_POLL_INTERVAL: int = int(os.environ.get("NOTIFICATION_POLL_INTERVAL", "120"))
|
|
||||||
# ID группы исполнителей для фильтрации заявок
|
# ID группы исполнителей для фильтрации заявок
|
||||||
INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "")
|
INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "")
|
||||||
# Список ID исполнителей через запятую (доп. фильтр)
|
# Список ID исполнителей через запятую (доп. фильтр)
|
||||||
|
|
|
||||||
BIN
data/support.db
BIN
data/support.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -45,32 +45,6 @@ async def init_db() -> None:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # колонка уже существует
|
pass # колонка уже существует
|
||||||
|
|
||||||
# Таблица слежения за задачами для push-уведомлений
|
|
||||||
await _db.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_watch (
|
|
||||||
task_id INTEGER PRIMARY KEY,
|
|
||||||
client_id INTEGER NOT NULL,
|
|
||||||
task_number INTEGER,
|
|
||||||
last_updatedat TEXT NOT NULL DEFAULT '',
|
|
||||||
last_comment_date TEXT NOT NULL DEFAULT ''
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
await _db.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_task_watch_client ON task_watch(client_id)"
|
|
||||||
)
|
|
||||||
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:
|
def get_db() -> aiosqlite.Connection:
|
||||||
if _db is None:
|
if _db is None:
|
||||||
|
|
|
||||||
|
|
@ -93,16 +93,6 @@ async def delete_bot_user(user_id: int) -> None:
|
||||||
await db.commit()
|
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:
|
async def unblock_bot_user(user_id: int, org_id: int) -> None:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
"""Контекст быстрого ответа: последняя активная заявка пользователя."""
|
|
||||||
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()
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"""Запросы к таблице task_watch — слежение за задачами для push-уведомлений."""
|
|
||||||
from database.connection import get_db
|
|
||||||
|
|
||||||
|
|
||||||
async def get_watched_clients() -> list[dict]:
|
|
||||||
"""Все организации с привязанным intradesk_id."""
|
|
||||||
db = get_db()
|
|
||||||
async with db.execute(
|
|
||||||
"SELECT id AS org_id, intradesk_id FROM organizations WHERE intradesk_id IS NOT NULL"
|
|
||||||
) as cur:
|
|
||||||
rows = await cur.fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_task_watch(task_id: int) -> dict | None:
|
|
||||||
"""Получить состояние слежения для одной задачи."""
|
|
||||||
db = get_db()
|
|
||||||
async with db.execute(
|
|
||||||
"SELECT * FROM task_watch WHERE task_id = ?", (task_id,)
|
|
||||||
) as cur:
|
|
||||||
row = await cur.fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def upsert_task_watch(
|
|
||||||
task_id: int,
|
|
||||||
client_id: int,
|
|
||||||
task_number: int | None,
|
|
||||||
last_updatedat: str,
|
|
||||||
last_comment_date: str,
|
|
||||||
) -> None:
|
|
||||||
"""Создать или обновить запись слежения."""
|
|
||||||
db = get_db()
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO task_watch (task_id, client_id, task_number, last_updatedat, last_comment_date)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(task_id) DO UPDATE SET
|
|
||||||
last_updatedat = excluded.last_updatedat,
|
|
||||||
last_comment_date = excluded.last_comment_date,
|
|
||||||
task_number = COALESCE(excluded.task_number, task_number)
|
|
||||||
""",
|
|
||||||
(task_id, client_id, task_number, last_updatedat, last_comment_date),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_tasks_not_in(client_id: int, task_ids: set[int]) -> None:
|
|
||||||
"""Удалить закрытые/архивные задачи клиента из слежения."""
|
|
||||||
db = get_db()
|
|
||||||
if task_ids:
|
|
||||||
placeholders = ",".join("?" * len(task_ids))
|
|
||||||
await db.execute(
|
|
||||||
f"DELETE FROM task_watch WHERE client_id = ? AND task_id NOT IN ({placeholders})",
|
|
||||||
(client_id, *task_ids),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Все задачи закрыты — убираем всё по клиенту
|
|
||||||
await db.execute("DELETE FROM task_watch WHERE client_id = ?", (client_id,))
|
|
||||||
await db.commit()
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
services:
|
services:
|
||||||
support_bot:
|
support_bot:
|
||||||
build: .
|
build: .
|
||||||
container_name: support_bot
|
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
DB_PATH: /app/data/support.db
|
DB_PATH: /app/data/support.db
|
||||||
|
|
@ -11,8 +10,3 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "5"
|
|
||||||
|
|
|
||||||
649
external/intradesk_api.py
vendored
649
external/intradesk_api.py
vendored
|
|
@ -3,21 +3,12 @@ Intradesk REST API: создание заявок и чтение истории
|
||||||
|
|
||||||
Эндпоинты:
|
Эндпоинты:
|
||||||
POST /changes/v3/tasks — создать заявку
|
POST /changes/v3/tasks — создать заявку
|
||||||
PUT /changes/v3/tasks — изменить заявку / добавить комментарий
|
|
||||||
GET /taskhistory/api/v2.0/lifetime/{id}/full — история (комментарии, смена статуса...)
|
GET /taskhistory/api/v2.0/lifetime/{id}/full — история (комментарии, смена статуса...)
|
||||||
|
|
||||||
Кэширование:
|
|
||||||
Все GET-запросы кэшируются в памяти с TTL, чтобы не дёргать API при каждом
|
|
||||||
переходе между экранами. Мутации (create/comment) сбрасывают нужные ключи.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -25,216 +16,21 @@ import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── HTML-очистка ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_TAG_RE = re.compile(r"<[^>]+>")
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_html(text: str) -> str:
|
|
||||||
"""Убрать HTML-теги и лишние пробелы из текста комментария."""
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
cleaned = _TAG_RE.sub("", text)
|
|
||||||
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
|
|
||||||
# ── TTL-кэш ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Структура: ключ → (expires_at: float, данные: Any)
|
|
||||||
_cache: dict[str, tuple[float, Any]] = {}
|
|
||||||
|
|
||||||
# TTL по типу данных
|
|
||||||
_TTL_COUNT = 60.0 # count_client_open_tasks
|
|
||||||
_TTL_LAST_COMMENT = 60.0 # get_client_last_comment
|
|
||||||
_TTL_OPEN_TASKS = 30.0 # get_client_open_tasks (список страниц)
|
|
||||||
_TTL_TASK_COMMENT = 30.0 # get_task_last_comment (история одной задачи)
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_get(key: str) -> tuple[bool, Any]:
|
|
||||||
"""Вернуть (True, значение) если ключ свежий, иначе (False, None)."""
|
|
||||||
entry = _cache.get(key)
|
|
||||||
if entry is None:
|
|
||||||
return False, None
|
|
||||||
expires_at, data = entry
|
|
||||||
if time.monotonic() > expires_at:
|
|
||||||
del _cache[key]
|
|
||||||
return False, None
|
|
||||||
return True, data
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_set(key: str, value: Any, ttl: float) -> None:
|
|
||||||
_cache[key] = (time.monotonic() + ttl, value)
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_delete_prefix(prefix: str) -> None:
|
|
||||||
"""Удалить все ключи начинающиеся с prefix."""
|
|
||||||
for k in [k for k in _cache if k.startswith(prefix)]:
|
|
||||||
del _cache[k]
|
|
||||||
|
|
||||||
|
|
||||||
def invalidate_client_cache(client_id: int) -> None:
|
|
||||||
"""
|
|
||||||
Сбросить кэш клиента после мутации (создание заявки и т.п.).
|
|
||||||
|
|
||||||
Очищает: счётчик, последний комментарий, все страницы списка заявок.
|
|
||||||
"""
|
|
||||||
_cache_delete_prefix(f"count:{client_id}")
|
|
||||||
_cache_delete_prefix(f"last_comment:{client_id}")
|
|
||||||
_cache_delete_prefix(f"open_tasks:{client_id}:")
|
|
||||||
logger.debug("cache invalidated for client_id=%s", client_id)
|
|
||||||
|
|
||||||
|
|
||||||
def invalidate_task_comment(task_id: int) -> None:
|
|
||||||
"""Сбросить кэш истории конкретной задачи после добавления комментария."""
|
|
||||||
_cache.pop(f"task_comment:{task_id}", None)
|
|
||||||
_cache_delete_prefix(f"task_comments:{task_id}:")
|
|
||||||
logger.debug("cache invalidated for task_id=%s", task_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Загрузка файлов ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _default_filename(att_type: str) -> str:
|
|
||||||
t = str(att_type).upper()
|
|
||||||
if "IMAGE" in t or "PHOTO" in t:
|
|
||||||
return "image.jpg"
|
|
||||||
if "VIDEO" in t:
|
|
||||||
return "video.mp4"
|
|
||||||
if "AUDIO" in t:
|
|
||||||
return "audio.wav"
|
|
||||||
return "file.bin"
|
|
||||||
|
|
||||||
|
|
||||||
async def upload_file_to_intradesk(
|
|
||||||
file_bytes: bytes,
|
|
||||||
filename: str,
|
|
||||||
content_type: str,
|
|
||||||
task_id: int = 0,
|
|
||||||
target: str = "Description",
|
|
||||||
) -> dict | None:
|
|
||||||
"""
|
|
||||||
Загрузить файл в Intradesk.
|
|
||||||
|
|
||||||
task_id=0 — для новой заявки (описание).
|
|
||||||
task_id=N — для существующей заявки (комментарий).
|
|
||||||
target — "Description" (30) или "Comment" (20).
|
|
||||||
|
|
||||||
Возвращает metadata файла {"id", "name", "contentType", ...} или None.
|
|
||||||
"""
|
|
||||||
key = config.INTRADESK_CHANGES_API_KEY
|
|
||||||
if not key:
|
|
||||||
logger.warning("upload_file_to_intradesk: INTRADESK_CHANGES_API_KEY не задан")
|
|
||||||
return None
|
|
||||||
|
|
||||||
url = (
|
|
||||||
f"{config.INTRADESK_BASE_URL.rstrip('/')}"
|
|
||||||
f"/files/api/tasks/{task_id}/files/target/{target}"
|
|
||||||
f"?ApiKey={key}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if config.DRY_RUN:
|
|
||||||
print(
|
|
||||||
f"\n[DRY-RUN] upload_file_to_intradesk — {filename} ({len(file_bytes)} bytes)\n"
|
|
||||||
f" POST {url}\n"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(
|
|
||||||
url,
|
|
||||||
files={"file": (filename, file_bytes, content_type)},
|
|
||||||
timeout=30.0,
|
|
||||||
)
|
|
||||||
if resp.status_code not in (200, 201):
|
|
||||||
logger.error(
|
|
||||||
"upload_file_to_intradesk → HTTP %s: %s",
|
|
||||||
resp.status_code, resp.text[:300],
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
data = resp.json()
|
|
||||||
meta = data[0] if isinstance(data, list) and data else data
|
|
||||||
if isinstance(meta, dict):
|
|
||||||
logger.info("upload_file_to_intradesk ok: %s → id=%s", filename, meta.get("id"))
|
|
||||||
return meta if isinstance(meta, dict) else None
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("upload_file_to_intradesk error: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def download_and_upload(
|
|
||||||
file_url: str,
|
|
||||||
filename: str | None,
|
|
||||||
content_type_hint: str | None,
|
|
||||||
task_id: int = 0,
|
|
||||||
target: str = "Description",
|
|
||||||
) -> dict | None:
|
|
||||||
"""Скачать файл из MAX CDN и загрузить в Intradesk."""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(file_url, timeout=30.0, follow_redirects=True)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning("download_and_upload: GET %s → HTTP %s", file_url, resp.status_code)
|
|
||||||
return None
|
|
||||||
file_bytes = resp.content
|
|
||||||
ct_raw = resp.headers.get("content-type", content_type_hint or "application/octet-stream")
|
|
||||||
ct = ct_raw.split(";")[0].strip()
|
|
||||||
fname = filename or "attachment"
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("download_and_upload download error: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return await upload_file_to_intradesk(file_bytes, fname, ct, task_id=task_id, target=target)
|
|
||||||
|
|
||||||
|
|
||||||
async def upload_attachments(
|
|
||||||
attachments: list[dict],
|
|
||||||
task_id: int = 0,
|
|
||||||
target: str = "Description",
|
|
||||||
) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Скачать и загрузить все вложения в Intradesk параллельно.
|
|
||||||
|
|
||||||
Возвращает те же вложения, обогащённые полем ``intradesk_meta``.
|
|
||||||
Если загрузка файла не удалась — поле будет None, вложение остаётся в списке.
|
|
||||||
"""
|
|
||||||
async def _process(att: dict) -> dict:
|
|
||||||
url = att.get("url")
|
|
||||||
if not url:
|
|
||||||
return att
|
|
||||||
filename = att.get("filename") or _default_filename(att.get("type", ""))
|
|
||||||
meta = await download_and_upload(url, filename, None, task_id=task_id, target=target)
|
|
||||||
return {**att, "intradesk_meta": meta}
|
|
||||||
|
|
||||||
results = await asyncio.gather(*[_process(a) for a in attachments], return_exceptions=True)
|
|
||||||
enriched = []
|
|
||||||
for orig, result in zip(attachments, results):
|
|
||||||
enriched.append(result if isinstance(result, dict) else orig)
|
|
||||||
return enriched
|
|
||||||
|
|
||||||
|
|
||||||
# ── API-функции ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def get_task_last_comment(task_id: int) -> dict | None:
|
async def get_task_last_comment(task_id: int) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Получить последний комментарий к заявке из Intradesk.
|
Получить последний комментарий к заявке из Intradesk.
|
||||||
|
|
||||||
Возвращает dict: {text, author, date} или None если нет комментариев.
|
Возвращает dict: {text, author, date} или None если нет комментариев.
|
||||||
Результат кэшируется на _TTL_TASK_COMMENT секунд.
|
|
||||||
"""
|
"""
|
||||||
ck = f"task_comment:{task_id}"
|
key = config.INTRADESK_TASKS_API_KEY
|
||||||
hit, cached = _cache_get(ck)
|
if not key:
|
||||||
if hit:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
api_key = config.INTRADESK_TASKS_API_KEY
|
|
||||||
if not api_key:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
f"{config.INTRADESK_BASE_URL.rstrip('/')}"
|
f"{config.INTRADESK_BASE_URL.rstrip('/')}"
|
||||||
f"/taskhistory/api/v2.0/lifetime/{task_id}/full"
|
f"/taskhistory/api/v2.0/lifetime/{task_id}/full"
|
||||||
f"?ApiKey={api_key}&sortDirection=Desc&top=10&events=50"
|
f"?ApiKey={key}&sortDirection=Desc&top=10&events=50"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|
@ -249,462 +45,58 @@ async def get_task_last_comment(task_id: int) -> dict | None:
|
||||||
logger.warning("get_task_last_comment error: %s", exc)
|
logger.warning("get_task_last_comment error: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result: dict | None = None
|
# Ищем последнее событие с blockname="comment" (type=50)
|
||||||
for entry in data.get("data") or []:
|
for entry in data.get("data") or []:
|
||||||
for ev in (entry.get("events") or {}).get("data") or []:
|
for ev in (entry.get("events") or {}).get("data") or []:
|
||||||
if ev.get("blockname") == "comment" and ev.get("type") == 50:
|
if ev.get("blockname") == "comment" and ev.get("type") == 50:
|
||||||
raw_date = entry.get("eventat") or ""
|
raw_date = entry.get("eventat") or ""
|
||||||
result = {
|
# "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54"
|
||||||
"text": _strip_html(ev.get("stringvalue") or ""),
|
short_date = raw_date[:16].replace("T", " ")
|
||||||
"author": entry.get("username") or "—",
|
|
||||||
"date": raw_date[:16].replace("T", " "),
|
|
||||||
}
|
|
||||||
break
|
|
||||||
if result:
|
|
||||||
break
|
|
||||||
|
|
||||||
_cache_set(ck, result, _TTL_TASK_COMMENT)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def get_task_comments(task_id: int, count: int = 10) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Получить последние комментарии к заявке.
|
|
||||||
|
|
||||||
Возвращает список {text, author, date}, до count штук, новые сначала.
|
|
||||||
Результат кэшируется на _TTL_TASK_COMMENT секунд.
|
|
||||||
"""
|
|
||||||
ck = f"task_comments:{task_id}:{count}"
|
|
||||||
hit, cached = _cache_get(ck)
|
|
||||||
if hit:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
api_key = config.INTRADESK_TASKS_API_KEY
|
|
||||||
if not api_key:
|
|
||||||
return []
|
|
||||||
|
|
||||||
url = (
|
|
||||||
f"{config.INTRADESK_BASE_URL.rstrip('/')}"
|
|
||||||
f"/taskhistory/api/v2.0/lifetime/{task_id}/full"
|
|
||||||
f"?ApiKey={api_key}&sortDirection=Desc&top={count * 3}&events=50"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(url, timeout=15.0)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning("get_task_comments task=%s → HTTP %s", task_id, resp.status_code)
|
|
||||||
return []
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("get_task_comments error: %s", exc)
|
|
||||||
return []
|
|
||||||
|
|
||||||
result: list[dict] = []
|
|
||||||
for entry in data.get("data") or []:
|
|
||||||
for ev in (entry.get("events") or {}).get("data") or []:
|
|
||||||
if ev.get("blockname") == "comment" and ev.get("type") == 50:
|
|
||||||
raw_date = entry.get("eventat") or ""
|
|
||||||
result.append({
|
|
||||||
"text": _strip_html(ev.get("stringvalue") or ""),
|
|
||||||
"author": entry.get("username") or "—",
|
|
||||||
"date": raw_date[:16].replace("T", " "),
|
|
||||||
})
|
|
||||||
break # одна запись = одно событие комментария
|
|
||||||
if len(result) >= count:
|
|
||||||
break
|
|
||||||
|
|
||||||
_cache_set(ck, result, _TTL_TASK_COMMENT)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def count_client_open_tasks(client_id: int) -> int:
|
|
||||||
"""
|
|
||||||
Количество незакрытых заявок клиента.
|
|
||||||
|
|
||||||
Intradesk не возвращает @odata.count при $top=0, поэтому берём $top=1.
|
|
||||||
Результат кэшируется на _TTL_COUNT секунд.
|
|
||||||
"""
|
|
||||||
ck = f"count:{client_id}"
|
|
||||||
hit, cached = _cache_get(ck)
|
|
||||||
if hit:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
api_key = config.INTRADESK_TASKS_API_KEY
|
|
||||||
if not api_key:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
url = (
|
|
||||||
f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks"
|
|
||||||
f"?ApiKey={api_key}"
|
|
||||||
f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)"
|
|
||||||
f"&$top=1&$count=true"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(url, timeout=10.0)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
return 0
|
|
||||||
data = resp.json()
|
|
||||||
count = data.get("@odata.count")
|
|
||||||
result = int(count) if count is not None else len(data.get("value") or [])
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
_cache_set(ck, result, _TTL_COUNT)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def get_client_last_comment(client_id: int) -> dict | None:
|
|
||||||
"""
|
|
||||||
Найти последний комментарий по любой незакрытой заявке клиента.
|
|
||||||
|
|
||||||
Берём задачу с самым свежим updatedat → ищем комментарий в её истории.
|
|
||||||
Возвращает {text, author, date, task_number} или None.
|
|
||||||
Результат кэшируется на _TTL_LAST_COMMENT секунд.
|
|
||||||
"""
|
|
||||||
ck = f"last_comment:{client_id}"
|
|
||||||
hit, cached = _cache_get(ck)
|
|
||||||
if hit:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
api_key = config.INTRADESK_TASKS_API_KEY
|
|
||||||
if not api_key:
|
|
||||||
return None
|
|
||||||
|
|
||||||
url = (
|
|
||||||
f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks"
|
|
||||||
f"?ApiKey={api_key}"
|
|
||||||
f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)"
|
|
||||||
f"&$orderby=updatedat desc&$top=1"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(url, timeout=15.0)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
return None
|
|
||||||
data = resp.json()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
tasks = data.get("value") or []
|
|
||||||
if not tasks:
|
|
||||||
_cache_set(ck, None, _TTL_LAST_COMMENT)
|
|
||||||
return None
|
|
||||||
|
|
||||||
task = tasks[0]
|
|
||||||
task_id = task.get("id")
|
|
||||||
task_number = task.get("tasknumber")
|
|
||||||
if not task_id:
|
|
||||||
_cache_set(ck, None, _TTL_LAST_COMMENT)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# get_task_last_comment имеет собственный кэш — двойного запроса не будет
|
|
||||||
comment = await get_task_last_comment(task_id)
|
|
||||||
if comment:
|
|
||||||
comment = {**comment, "task_number": task_number}
|
|
||||||
|
|
||||||
_cache_set(ck, comment, _TTL_LAST_COMMENT)
|
|
||||||
return comment
|
|
||||||
|
|
||||||
|
|
||||||
async def get_client_open_tasks(
|
|
||||||
client_id: int,
|
|
||||||
page: int = 0,
|
|
||||||
page_size: int = 5,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Получить незакрытые заявки клиента из Intradesk API.
|
|
||||||
|
|
||||||
Возвращает {"tasks": [...], "total": int}.
|
|
||||||
Последний комментарий загружается параллельно через history API.
|
|
||||||
Результат кэшируется на _TTL_OPEN_TASKS секунд.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
ck = f"open_tasks:{client_id}:{page}:{page_size}"
|
|
||||||
hit, cached = _cache_get(ck)
|
|
||||||
if hit:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
api_key = config.INTRADESK_TASKS_API_KEY
|
|
||||||
if not api_key:
|
|
||||||
return {"tasks": [], "total": 0}
|
|
||||||
|
|
||||||
skip = page * page_size
|
|
||||||
url = (
|
|
||||||
f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks"
|
|
||||||
f"?ApiKey={api_key}"
|
|
||||||
f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)"
|
|
||||||
f"&$orderby=updatedat desc"
|
|
||||||
f"&$top={page_size}&$skip={skip}"
|
|
||||||
f"&$count=true"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(url, timeout=20.0)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning("get_client_open_tasks client=%s → HTTP %s", client_id, resp.status_code)
|
|
||||||
return {"tasks": [], "total": 0}
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("get_client_open_tasks error: %s", exc)
|
|
||||||
return {"tasks": [], "total": 0}
|
|
||||||
|
|
||||||
total = data.get("@odata.count") or 0
|
|
||||||
raw_tasks = data.get("value") or []
|
|
||||||
|
|
||||||
# Словари для разрешения имён статусов
|
|
||||||
dicts_map: dict[int, str] = {}
|
|
||||||
for d in data.get("dictionaries") or []:
|
|
||||||
did = d.get("id")
|
|
||||||
dname = d.get("name") or ""
|
|
||||||
if isinstance(dname, dict):
|
|
||||||
dname = dname.get("ru") or dname.get("en") or ""
|
|
||||||
if did:
|
|
||||||
dicts_map[did] = dname
|
|
||||||
|
|
||||||
base_tasks = []
|
|
||||||
for t in raw_tasks:
|
|
||||||
status_id = t.get("status")
|
|
||||||
base_tasks.append({
|
|
||||||
"id": t.get("id"),
|
|
||||||
"task_number": t.get("tasknumber"),
|
|
||||||
"name": t.get("name") or "",
|
|
||||||
"description": _strip_html(t.get("description") or ""),
|
|
||||||
"status_name": dicts_map.get(status_id, "") if status_id else "",
|
|
||||||
"created_at": t.get("createdat") or "",
|
|
||||||
"updated_at": t.get("updatedat") or "",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Параллельно загружаем последний комментарий (используют собственный кэш)
|
|
||||||
task_ids = [t["id"] for t in base_tasks if t["id"]]
|
|
||||||
comments = await asyncio.gather(
|
|
||||||
*[get_task_last_comment(tid) for tid in task_ids],
|
|
||||||
return_exceptions=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
tasks = []
|
|
||||||
for task, comment in zip(base_tasks, comments):
|
|
||||||
task["last_comment"] = comment if isinstance(comment, dict) else None
|
|
||||||
tasks.append(task)
|
|
||||||
|
|
||||||
result = {"tasks": tasks, "total": total}
|
|
||||||
_cache_set(ck, result, _TTL_OPEN_TASKS)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_all_open_tasks_lightweight() -> list[dict]:
|
|
||||||
"""
|
|
||||||
Получить ВСЕ открытые задачи (по всем клиентам) одним запросом.
|
|
||||||
|
|
||||||
Используется нотифайером — без кэша, без комментариев.
|
|
||||||
Возвращает [{id, task_number, client_id, updatedat}].
|
|
||||||
Нотифайер сам группирует результат по client_id.
|
|
||||||
"""
|
|
||||||
api_key = config.INTRADESK_TASKS_API_KEY
|
|
||||||
if not api_key:
|
|
||||||
return []
|
|
||||||
|
|
||||||
url = (
|
|
||||||
f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks"
|
|
||||||
f"?ApiKey={api_key}"
|
|
||||||
f"&$filter=(closedat eq null) and (isarchived eq false)"
|
|
||||||
f"&$orderby=updatedat desc"
|
|
||||||
f"&$top=500"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(url, timeout=30.0)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning("fetch_all_open_tasks_lightweight → HTTP %s", resp.status_code)
|
|
||||||
return []
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("fetch_all_open_tasks_lightweight error: %s", exc)
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": t.get("id"),
|
|
||||||
"task_number": t.get("tasknumber"),
|
|
||||||
"client_id": t.get("clientid"),
|
|
||||||
"updatedat": t.get("updatedat") or "",
|
|
||||||
}
|
|
||||||
for t in (data.get("value") or [])
|
|
||||||
if t.get("id") and t.get("clientid")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None:
|
|
||||||
"""Извлечь последний комментарий из поля lifetime задачи."""
|
|
||||||
if not lifetime:
|
|
||||||
return None
|
|
||||||
for entry in (lifetime.get("data") or []):
|
|
||||||
for ev in (entry.get("events") or {}).get("data") or []:
|
|
||||||
if ev.get("blockname") == "comment" and ev.get("type") == 50:
|
|
||||||
raw_date = entry.get("eventat") or ""
|
|
||||||
return {
|
return {
|
||||||
"text": _strip_html(ev.get("stringvalue") or ""),
|
"text": ev.get("stringvalue") or "",
|
||||||
"author": entry.get("username") or "—",
|
"author": entry.get("username") or "—",
|
||||||
"date": raw_date[:16].replace("T", " "),
|
"date": short_date,
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def add_task_comment(
|
|
||||||
task_number: int,
|
|
||||||
text: str,
|
|
||||||
contact_person_id: int | None = None,
|
|
||||||
attachments: list[dict] | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Добавить комментарий к заявке (PUT /changes/v3/tasks).
|
|
||||||
|
|
||||||
attachments — список вложений с полем ``intradesk_meta`` (заполняется
|
|
||||||
через upload_attachments с target="Comment" перед вызовом этой функции).
|
|
||||||
|
|
||||||
Возвращает True при успехе, False при ошибке.
|
|
||||||
Кэш задачи сбрасывается вызывающей стороной через invalidate_task_comment.
|
|
||||||
"""
|
|
||||||
key = config.INTRADESK_CHANGES_API_KEY
|
|
||||||
if not key:
|
|
||||||
logger.warning("add_task_comment: INTRADESK_CHANGES_API_KEY не задан")
|
|
||||||
return False
|
|
||||||
|
|
||||||
blocks: dict[str, str] = {
|
|
||||||
"comment": json.dumps({"value": text}, ensure_ascii=False),
|
|
||||||
}
|
|
||||||
add_files = [a["intradesk_meta"] for a in (attachments or []) if a.get("intradesk_meta")]
|
|
||||||
if add_files:
|
|
||||||
blocks["attachments"] = json.dumps(
|
|
||||||
{"value": {"addFiles": add_files, "deleteFileIds": []}},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
body = {"number": task_number, "blocks": blocks, "Channel": "api"}
|
|
||||||
url = f"{config.INTRADESK_BASE_URL.rstrip('/')}/changes/v3/tasks?ApiKey={key}"
|
|
||||||
if contact_person_id is not None:
|
|
||||||
url += f"&userid={contact_person_id}"
|
|
||||||
|
|
||||||
if config.DRY_RUN:
|
|
||||||
print(
|
|
||||||
"\n[DRY-RUN] add_task_comment — запрос НЕ отправлен\n"
|
|
||||||
f" PUT {url}\n"
|
|
||||||
f" Body: {json.dumps(body, ensure_ascii=False, indent=2)}\n"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.put(
|
|
||||||
url,
|
|
||||||
json=body,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
timeout=20.0,
|
|
||||||
)
|
|
||||||
if resp.status_code not in (200, 201):
|
|
||||||
logger.error(
|
|
||||||
"add_task_comment → HTTP %s: %s",
|
|
||||||
resp.status_code,
|
|
||||||
resp.text[:500],
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
logger.info("add_task_comment ok: task_number=%s", task_number)
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("add_task_comment error: %s", exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def close_task(
|
|
||||||
task_number: int,
|
|
||||||
contact_person_id: int | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Закрыть заявку — установить статус INTRADESK_CLOSE_STATUS_ID (PUT /changes/v3/tasks).
|
|
||||||
|
|
||||||
Возвращает True при успехе, False при ошибке или незаданном статусе.
|
|
||||||
"""
|
|
||||||
status_id = config.INTRADESK_CLOSE_STATUS_ID
|
|
||||||
if not status_id:
|
|
||||||
logger.warning("close_task: INTRADESK_CLOSE_STATUS_ID не задан")
|
|
||||||
return False
|
|
||||||
|
|
||||||
key = config.INTRADESK_CHANGES_API_KEY
|
|
||||||
if not key:
|
|
||||||
logger.warning("close_task: INTRADESK_CHANGES_API_KEY не задан")
|
|
||||||
return False
|
|
||||||
|
|
||||||
blocks: dict[str, str] = {
|
|
||||||
"status": json.dumps({"value": int(status_id)}, ensure_ascii=False),
|
|
||||||
}
|
|
||||||
body = {"number": task_number, "blocks": blocks, "Channel": "api"}
|
|
||||||
url = f"{config.INTRADESK_BASE_URL.rstrip('/')}/changes/v3/tasks?ApiKey={key}"
|
|
||||||
if contact_person_id is not None:
|
|
||||||
url += f"&userid={contact_person_id}"
|
|
||||||
|
|
||||||
if config.DRY_RUN:
|
|
||||||
print(
|
|
||||||
"\n[DRY-RUN] close_task — запрос НЕ отправлен\n"
|
|
||||||
f" PUT {url}\n"
|
|
||||||
f" Body: {json.dumps(body, ensure_ascii=False, indent=2)}\n"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.put(
|
|
||||||
url,
|
|
||||||
json=body,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
timeout=20.0,
|
|
||||||
)
|
|
||||||
if resp.status_code not in (200, 201):
|
|
||||||
logger.error("close_task → HTTP %s: %s", resp.status_code, resp.text[:500])
|
|
||||||
return False
|
|
||||||
logger.info("close_task ok: task_number=%s", task_number)
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("close_task error: %s", exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def create_intradesk_task(
|
async def create_intradesk_task(
|
||||||
name: str,
|
name: str,
|
||||||
description: str,
|
description: str,
|
||||||
client_intradesk_id: int | None = None,
|
client_intradesk_id: int | None = None,
|
||||||
contact_person_id: int | None = None,
|
contact_person_id: int | None = None,
|
||||||
attachments: list[dict] | None = None,
|
attachment_urls: list[str] | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Создать заявку в Intradesk.
|
Создать заявку в Intradesk.
|
||||||
|
|
||||||
attachments — список вложений с полем ``intradesk_meta`` (заполняется
|
|
||||||
через upload_attachments с target="Description" перед вызовом этой функции).
|
|
||||||
|
|
||||||
Возвращает ответ API (dict с полями id, tasknumber и др.) или None при ошибке.
|
Возвращает ответ API (dict с полями id, tasknumber и др.) или None при ошибке.
|
||||||
Кэш клиента сбрасывается вызывающей стороной через invalidate_client_cache.
|
Вложения — пока добавляются как ссылки в конец описания (до появления upload API).
|
||||||
|
В dry-run режиме запрос печатается в консоль и возвращается None.
|
||||||
"""
|
"""
|
||||||
key = config.INTRADESK_CHANGES_API_KEY
|
key = config.INTRADESK_CHANGES_API_KEY
|
||||||
if not key:
|
if not key:
|
||||||
logger.warning("create_intradesk_task: INTRADESK_CHANGES_API_KEY не задан")
|
logger.warning("create_intradesk_task: INTRADESK_CHANGES_API_KEY не задан")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Вложения — ссылки в конце описания
|
||||||
|
full_desc = description
|
||||||
|
if attachment_urls:
|
||||||
|
full_desc += "\n\nВложения:\n" + "\n".join(attachment_urls)
|
||||||
|
|
||||||
blocks: dict[str, str] = {
|
blocks: dict[str, str] = {
|
||||||
"name": json.dumps({"value": name}, ensure_ascii=False),
|
"name": json.dumps({"value": name}, ensure_ascii=False),
|
||||||
"description": json.dumps({"value": description}, ensure_ascii=False),
|
"description": json.dumps({"value": full_desc}, ensure_ascii=False),
|
||||||
}
|
}
|
||||||
add_files = [a["intradesk_meta"] for a in (attachments or []) if a.get("intradesk_meta")]
|
# initiator: вариант 3 из документации — клиент + пользователь клиента.
|
||||||
if add_files:
|
# clientid заполняется автоматически из initiatorclient, отдельно не нужен.
|
||||||
blocks["attachments"] = json.dumps(
|
|
||||||
{"value": {"addFiles": add_files, "deleteFileIds": []}},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
if client_intradesk_id is not None and contact_person_id is not None:
|
if client_intradesk_id is not None and contact_person_id is not None:
|
||||||
blocks["initiator"] = json.dumps(
|
blocks["initiator"] = json.dumps(
|
||||||
{"value": {"groupid": client_intradesk_id, "userid": contact_person_id}},
|
{"value": {"groupid": client_intradesk_id, "userid": contact_person_id}},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
elif client_intradesk_id is not None:
|
elif client_intradesk_id is not None:
|
||||||
|
# только клиент (нет конкретного контактного лица)
|
||||||
blocks["initiator"] = json.dumps(
|
blocks["initiator"] = json.dumps(
|
||||||
{"value": {"groupid": client_intradesk_id}}, ensure_ascii=False
|
{"value": {"groupid": client_intradesk_id}}, ensure_ascii=False
|
||||||
)
|
)
|
||||||
|
|
@ -746,6 +138,7 @@ async def create_intradesk_task(
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
|
# API может вернуть как "Number"/"Id" (PascalCase), так и "number"/"id"
|
||||||
task_id = result.get("id") or result.get("Id") or result.get("ID")
|
task_id = result.get("id") or result.get("Id") or result.get("ID")
|
||||||
task_number = (
|
task_number = (
|
||||||
result.get("tasknumber")
|
result.get("tasknumber")
|
||||||
|
|
|
||||||
15
main.py
15
main.py
|
|
@ -6,9 +6,7 @@ setup_logging() # до всех остальных импортов
|
||||||
|
|
||||||
from database.connection import init_db
|
from database.connection import init_db
|
||||||
from bot.setup import create_bot_and_dispatcher
|
from bot.setup import create_bot_and_dispatcher
|
||||||
from bot.notifier import run_notifier
|
|
||||||
from admin.app import create_admin_app
|
from admin.app import create_admin_app
|
||||||
from maxapi.types.command import BotCommand
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,18 +23,6 @@ async def main() -> None:
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
bot, dp = create_bot_and_dispatcher()
|
bot, dp = create_bot_and_dispatcher()
|
||||||
|
|
||||||
# Регистрируем команды — появляются в меню кнопки "/" в поле ввода
|
|
||||||
try:
|
|
||||||
await bot.set_my_commands(
|
|
||||||
BotCommand(name="start", description="Главное меню"),
|
|
||||||
BotCommand(name="menu", description="Показать меню"),
|
|
||||||
BotCommand(name="cancel", description="Отменить текущее действие"),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).warning("set_my_commands failed: %s", e)
|
|
||||||
|
|
||||||
admin_app = create_admin_app()
|
admin_app = create_admin_app()
|
||||||
|
|
||||||
server = uvicorn.Server(
|
server = uvicorn.Server(
|
||||||
|
|
@ -51,7 +37,6 @@ async def main() -> None:
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
dp.start_polling(bot, skip_updates=True),
|
dp.start_polling(bot, skip_updates=True),
|
||||||
server.serve(),
|
server.serve(),
|
||||||
run_notifier(bot),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,3 @@ aiosqlite
|
||||||
python-dotenv
|
python-dotenv
|
||||||
python-multipart
|
python-multipart
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
brotli
|
|
||||||
|
|
|
||||||
3
run.bat
3
run.bat
|
|
@ -1,3 +1,6 @@
|
||||||
|
echo [INFO] Запуск... Admin-панель: http://localhost:8080/admin/
|
||||||
|
echo.
|
||||||
|
|
||||||
python main.py
|
python main.py
|
||||||
|
|
||||||
if %ERRORLEVEL% neq 0 (
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue