работа с файлами

This commit is contained in:
dv 2026-03-30 16:14:07 +03:00
parent 96233afa87
commit 2693dc496d
16 changed files with 754 additions and 85 deletions

View file

@ -24,6 +24,7 @@ from maxapi.types.updates.message_created import MessageCreated
from bot.handlers.menu import build_main_menu_text
from bot.keyboards import confirm_ticket_kb, done_ticket_kb, main_menu_kb
from bot.logging_setup import get_logger
from bot.nav_helper import nav_set
from bot.send_helper import bot_send
from bot.states import TicketStates
from database.connection import get_db
@ -31,7 +32,7 @@ from database.queries.intradesk import get_intradesk_contact_id_for_phone
from database.queries.organizations import get_organization
from database.queries.ticket_attachments import add_attachment
from database.queries.tickets import create_ticket, set_intradesk_task_id
from external.intradesk_api import create_intradesk_task, invalidate_client_cache
from external.intradesk_api import create_intradesk_task, invalidate_client_cache, upload_attachments
logger = get_logger("bot.handlers.create_ticket")
@ -188,7 +189,7 @@ async def cb_create_ticket(
text=(
"📝 Опишите вашу проблему.\n\n"
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
"Когда закончите — нажмите кнопку «Готово»."
"Когда закончите — нажмите «👁 Перейти к предпросмотру»."
),
attachments=[done_ticket_kb()],
)
@ -207,9 +208,41 @@ async def on_ticket_description(
body = event.message.body
text = (body.text or "").strip() if body else ""
attachments = _extract_attachments(event)
new_atts = _extract_attachments(event)
await context.update_data(description=text, pending_attachments=attachments)
if not text:
if not new_atts:
# Стикер/локация/etc — просим написать описание
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
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="✅ Вложение принято! Теперь напишите описание проблемы текстом:",
attachments=[done_ticket_kb()],
)
await _track_mid(context, mid)
return
# Есть текст — объединяем с ранее добавленными вложениями
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(TicketStates.collecting_attachments)
mid = await bot_send(
@ -218,7 +251,7 @@ async def on_ticket_description(
user_id=user_id,
text=(
"✏️ Описание принято. Вы можете добавить ещё файлы "
"или нажмите «Готово» для отправки заявки."
"или нажмите «👁 Перейти к предпросмотру»."
),
attachments=[done_ticket_kb()],
)
@ -313,7 +346,7 @@ async def cb_edit_ticket(
user_id=bot_user["max_user_id"],
text=(
"✏️ Продолжайте добавлять описание или вложения.\n"
"Нажмите «Готово» когда будете готовы."
"Нажмите «👁 Перейти к предпросмотру» когда будете готовы."
),
attachments=[done_ticket_kb()],
)
@ -324,7 +357,11 @@ async def cb_edit_ticket(
@router.message_callback(
F.callback.payload == "cancel_ticket",
states=TicketStates.confirming,
states=[
TicketStates.waiting_description,
TicketStates.collecting_attachments,
TicketStates.confirming,
],
)
async def cb_cancel_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
@ -340,13 +377,14 @@ async def cb_cancel_ticket(
if chat_id is None:
return
await bot_send(
menu_mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=await build_main_menu_text(bot_user),
attachments=[main_menu_kb()],
)
await nav_set(context, menu_mid)
# ── Шаг 5а: "Подтвердить" → создание ─────────────────────────────────────────
@ -373,9 +411,12 @@ async def cb_confirm_ticket(
if chat_id is None:
return
#await event.answer(notification="Отправляем заявку…")
await event.message.answer("⏳ Отправляем заявку…")
# ── Загружаем вложения в 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_name = org["name"] if org else None
@ -407,14 +448,13 @@ async def cb_confirm_ticket(
# ── Отправка в Intradesk (ждём, чтобы получить номер) ────────────────────
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(
name=task_name,
description=description,
client_intradesk_id=intradesk_id,
contact_person_id=contact_person_id,
attachment_urls=att_urls or None,
attachments=pending_atts or None,
)
intradesk_number: int | None = None
@ -455,10 +495,11 @@ async def cb_confirm_ticket(
text="\n".join(lines),
)
await bot_send(
menu_mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=await build_main_menu_text(bot_user),
attachments=[main_menu_kb()],
)
await nav_set(context, menu_mid)

View file

@ -7,7 +7,7 @@ from maxapi.filters import F
from maxapi.types.updates.message_callback import MessageCallback
from bot.keyboards import main_menu_kb
from bot.send_helper import bot_reply
from bot.nav_helper import nav_finish, nav_prepare
from database.queries.organizations import get_organization
from external.intradesk_api import count_client_open_tasks, get_client_last_comment
@ -51,7 +51,6 @@ async def build_main_menu_text(bot_user: dict) -> str:
if len(ctext) > 120:
ctext = ctext[:120] + ""
raw_date = last_comment.get("date") or ""
# raw_date приходит как "2026-03-26 11:54"
date_part = raw_date[:10]
time_part = raw_date[11:16] if len(raw_date) >= 16 else ""
if len(date_part) == 10:
@ -86,11 +85,23 @@ async def cb_main_menu(
await context.set_state(None)
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="⏳ Загружаем меню...",
chat_id=chat_id,
user_id=bot_user["max_user_id"],
)
text = await build_main_menu_text(bot_user)
await bot_reply(
event,
await nav_finish(
event.bot, context,
mid=mid,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=text,
attachments=[main_menu_kb()],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)

View file

@ -18,6 +18,7 @@ from maxapi.filters.command import Command
from bot.keyboards import contact_request_kb, main_menu_kb
from bot.logging_setup import get_logger
from bot.nav_helper import nav_set
from bot.send_helper import bot_send
from bot.states import AuthStates
@ -70,13 +71,14 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
await context.set_state(None)
from bot.handlers.menu import build_main_menu_text
menu_text = "👋 С возвращением!\n\n" + await build_main_menu_text(bot_user)
await bot_send(
mid = await bot_send(
event.bot,
chat_id=event.chat_id,
user_id=user.user_id,
text=menu_text,
attachments=[main_menu_kb()],
)
await nav_set(context, mid)
return
# Новый пользователь — запрашиваем контакт
@ -146,13 +148,14 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
await context.set_state(None)
from bot.handlers.menu import build_main_menu_text
menu_text = await build_main_menu_text(bot_user)
await bot_send(
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text=menu_text,
attachments=[main_menu_kb()],
)
await nav_set(context, mid)
@router.message_created(ContactFilter(), states=AuthStates.waiting_contact)
@ -228,16 +231,19 @@ async def on_contact_shared(
)
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
menu_text = "✅ Вы успешно авторизованы!\n\n" + await build_main_menu_text(bot_user)
await bot_send(
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text=menu_text,
attachments=[main_menu_kb()],
)
await nav_set(context, mid)
# ── /menu ─────────────────────────────────────────────────────────────────────
@ -258,13 +264,14 @@ async def on_menu_command(event: MessageCreated, context: BaseContext) -> None:
await context.set_state(None)
from bot.handlers.menu import build_main_menu_text
await bot_send(
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 ───────────────────────────────────────────────────────────────────
@ -300,10 +307,11 @@ async def on_cancel_command(event: MessageCreated, context: BaseContext) -> None
await context.set_state(None)
from bot.handlers.menu import build_main_menu_text
await bot_send(
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)

View file

@ -20,8 +20,10 @@ from maxapi.types.updates.message_callback import MessageCallback
import config
from bot.filters import PayloadStartsWith
from bot.handlers.create_ticket import _extract_attachments
from bot.keyboards import comment_input_kb, iticket_detail_kb, main_menu_kb, menu_only_kb
from bot.send_helper import bot_reply, bot_send
from bot.nav_helper import nav_finish, nav_prepare, nav_set
from bot.send_helper import bot_send
from bot.states import CommentStates
from database.connection import get_db
from database.queries.intradesk import get_intradesk_contact_id_for_phone
@ -34,6 +36,7 @@ from external.intradesk_api import (
get_task_last_comment,
invalidate_client_cache,
invalidate_task_comment,
upload_attachments,
)
router = Router(router_id="view_tickets")
@ -102,18 +105,29 @@ def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
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 и показать.
Сразу редактирует сообщение на « Загрузка...», затем делает запрос к API.
Перед рендером проверяет load_token если пользователь успел уйти
в другой флоу, контекст изменится и мы тихо выходим.
nav_mid уже отображённый индикатор загрузки (из nav_prepare).
Если None отправляет новое сообщение самостоятельно.
Редактирует nav_mid результатом; перед рендером проверяет load_token.
"""
org_id = bot_user.get("org_id")
intradesk_client_id: int | None = None
@ -121,27 +135,30 @@ async def _load_and_show(
org = await get_organization(org_id)
intradesk_client_id = org.get("intradesk_id") if org else None
# Уникальный токен для этого запроса
token = str(time.monotonic_ns())
mid = event.message.body.mid if (event.message and event.message.body) else None
chat_id = event.message.recipient.chat_id if event.message else None
# Записываем токен в контекст и сразу показываем загрузку
await context.set_data({"load_token": token})
await bot_reply(
event,
text="⏳ Загружаем список заявок...",
attachments=None,
user_id=bot_user["max_user_id"],
chat_id=chat_id,
)
# Если 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="⏳ Загружаем список заявок...",
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, mid, chat_id,
await _edit_or_send(event.bot, nav_mid, chat_id,
text="⚠️ Ваша организация не привязана к Intradesk. Обратитесь к администратору.",
attachments=[menu_only_kb()])
return
@ -154,13 +171,15 @@ async def _load_and_show(
# Проверяем токен — не ушёл ли пользователь пока грузили
data = await context.get_data()
if data.get("load_token") != token:
return # Пользователь уже в другом флоу — не трогаем его экран
await _delete_msg(event.bot, nav_mid)
return
# Сохраняем задачи в контексте для детального просмотра
await context.set_data({"tasks": tasks, "page": page, "total": total})
data.update({"tasks": tasks, "page": page, "total": total, "nav_mid": nav_mid})
await context.set_data(data)
if not tasks:
await _edit_or_send(event.bot, mid, chat_id,
await _edit_or_send(event.bot, nav_mid, chat_id,
text="📭 Активных заявок нет.",
attachments=[menu_only_kb()])
return
@ -168,7 +187,7 @@ async def _load_and_show(
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, mid, chat_id, text=text, attachments=[kb])
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:
@ -194,7 +213,15 @@ async def cb_view_tickets(
from bot.handlers.create_ticket import cleanup_ticket_flow
await cleanup_ticket_flow(event.bot, context)
await event.answer(notification="")
await _load_and_show(event, bot_user, page=0, context=context)
chat_id = event.message.recipient.chat_id if event.message else None
nav_mid = await nav_prepare(
event, context,
loading_text="⏳ Загружаем список заявок...",
chat_id=chat_id,
user_id=bot_user["max_user_id"],
)
await _load_and_show(event, bot_user, page=0, context=context, nav_mid=nav_mid)
@router.message_callback(PayloadStartsWith("itickets_page:"))
@ -206,7 +233,15 @@ async def cb_itickets_page(
page = int(event.callback.payload.split(":")[1])
except (IndexError, ValueError):
page = 0
await _load_and_show(event, bot_user, page=page, context=context)
chat_id = event.message.recipient.chat_id if event.message else None
nav_mid = await nav_prepare(
event, context,
loading_text="⏳ Загружаем список заявок...",
chat_id=chat_id,
user_id=bot_user["max_user_id"],
)
await _load_and_show(event, bot_user, page=page, context=context, nav_mid=nav_mid)
@router.message_callback(PayloadStartsWith("iticket_detail:"))
@ -220,6 +255,7 @@ async def cb_iticket_detail(
return
await event.answer(notification="")
chat_id = event.message.recipient.chat_id if event.message else None
# Если пришли сюда из комментарийного флоу (кнопка «Назад») —
# удаляем промпт-сообщение и сбрасываем state, сохраняя кэш задач.
@ -227,28 +263,32 @@ async def cb_iticket_detail(
if state == CommentStates.waiting_text:
d = await context.get_data()
for mid in (d.get("flow_message_ids") or []):
try:
await event.bot.delete_message(message_id=mid)
except Exception:
pass
await _delete_msg(event.bot, mid)
await context.set_state(None)
# Сохраняем кэш задач, но сбрасываем 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="⏳ Загружаем заявку...",
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 bot_reply(
event,
await nav_finish(
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
text="⚠️ Заявка не найдена. Вернитесь к списку.",
attachments=[iticket_detail_kb()],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)
return
@ -280,19 +320,17 @@ async def cb_iticket_detail(
ctext = ctext[:300] + ""
lines += [
"",
f"" * 16,
"" * 16,
f"{c['date']}{c['author']}",
ctext,
]
else:
lines += ["", "💬 Комментариев нет"]
await bot_reply(
event,
await nav_finish(
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
text="\n".join(lines),
attachments=[iticket_detail_kb(task_id=task["id"], can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)
@ -315,16 +353,29 @@ async def cb_close_ticket(
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:
await bot_reply(
event,
nav_mid = await nav_prepare(
event, context,
loading_text="⏳ Закрываем заявку...",
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="⚠️ Не удалось определить номер заявки. Вернитесь к списку.",
attachments=[iticket_detail_kb()],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)
return
nav_mid = await nav_prepare(
event, context,
loading_text="⏳ Закрываем заявку...",
chat_id=chat_id,
user_id=bot_user["max_user_id"],
)
# Получаем contact_person_id
intradesk_id: int | None = None
contact_person_id: int | None = None
@ -345,20 +396,16 @@ async def cb_close_ticket(
if intradesk_id:
invalidate_client_cache(intradesk_id)
num_str = f" #{task_number}" if task_number else ""
await bot_reply(
event,
await nav_finish(
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
text=f"✅ Заявка{num_str} закрыта.",
attachments=[menu_only_kb()],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)
else:
await bot_reply(
event,
await nav_finish(
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
text="⚠️ Не удалось закрыть заявку. Попробуйте позже.",
attachments=[iticket_detail_kb(task_id=task_id, can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None,
)
@ -403,7 +450,8 @@ async def cb_add_comment(
user_id=bot_user["max_user_id"],
text=(
f"💬 Напишите комментарий{num_str}:\n\n"
"Отправьте текстовое сообщение или нажмите «← Назад к заявке» для отмены."
"Можно отправить текст, файл, фото или голосовое сообщение.\n"
"Нажмите «← Назад к заявке» для отмены."
),
attachments=[comment_input_kb(task_id)],
)
@ -424,12 +472,14 @@ async def on_comment_text(
body = event.message.body
text = (body.text or "").strip() if body else ""
if not text:
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="❗ Комментарий не может быть пустым. Напишите текст:",
text="❗ Комментарий не может быть пустым. Напишите текст или прикрепите файл:",
attachments=[menu_only_kb()],
)
return
@ -453,10 +503,19 @@ async def on_comment_text(
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 "📎 Файл прикреплён"
ok = await add_task_comment(
task_number=task_number,
text=text,
text=comment_text,
contact_person_id=contact_person_id,
attachments=file_atts or None,
)
if ok:
@ -484,10 +543,11 @@ async def on_comment_text(
text="⚠️ Не удалось отправить комментарий. Попробуйте позже.",
)
await bot_send(
menu_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, menu_mid)

View file

@ -47,6 +47,7 @@ def done_ticket_kb() -> Attachment:
return (
InlineKeyboardBuilder()
.row(CallbackButton(text="✅ Готово", payload="done_ticket"))
.row(CallbackButton(text="❌ Отменить создание", payload="cancel_ticket"))
.as_markup()
)
@ -146,6 +147,16 @@ def comment_input_kb(task_id: int) -> Attachment:
)
def notification_kb() -> Attachment:
"""Клавиатура под уведомлением о новом комментарии."""
return (
InlineKeyboardBuilder()
.row(CallbackButton(text="📋 Мои заявки", payload="view_tickets"))
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
.as_markup()
)
def ticket_detail_kb(page: int = 0) -> Attachment:
return (
InlineKeyboardBuilder()

110
bot/nav_helper.py Normal file
View file

@ -0,0 +1,110 @@
"""
Утилиты навигационных переходов.
Логика:
- Если кнопка нажата на последнем навигационном сообщении (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)

173
bot/notifier.py Normal file
View file

@ -0,0 +1,173 @@
"""
Фоновый опрос 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, task_number: int, comment: dict) -> None:
from bot.keyboards import notification_kb
ctext = comment["text"]
if len(ctext) > 200:
ctext = ctext[:200] + ""
text = (
f"💬 Новый ответ по заявке #{task_number}\n\n"
f"«{ctext}»\n"
f"{comment['author']}, {comment['date']}"
)
await bot.send_message(
chat_id=chat_id,
text=text,
attachments=[notification_kb()],
)
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")
if not chat_id:
continue
try:
await _send_notification(bot, chat_id, task_number, latest)
notified += 1
except Exception as exc:
logger.warning(
"notify failed user=%s task=%s: %s",
user.get("max_user_id"), task_number, exc,
)
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)

View file

@ -40,6 +40,8 @@ INTRADESK_DEFAULT_SERVICE: str = os.environ.get("INTRADESK_DEFAULT_SERVICE", "")
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 группы исполнителей для фильтрации заявок
INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "")
# Список ID исполнителей через запятую (доп. фильтр)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -45,6 +45,21 @@ async def init_db() -> None:
except Exception:
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()
def get_db() -> aiosqlite.Connection:
if _db is None:

View file

@ -0,0 +1,60 @@
"""Запросы к таблице 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()

View file

@ -12,6 +12,7 @@ Intradesk REST API: создание заявок и чтение истории
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
@ -91,6 +92,127 @@ def invalidate_task_comment(task_id: int) -> None:
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:
@ -371,6 +493,48 @@ async def get_client_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:
@ -391,10 +555,14 @@ 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.
"""
@ -406,6 +574,12 @@ async def add_task_comment(
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:
@ -499,11 +673,14 @@ async def create_intradesk_task(
description: str,
client_intradesk_id: int | None = None,
contact_person_id: int | None = None,
attachment_urls: list[str] | None = None,
attachments: list[dict] | None = None,
) -> dict | None:
"""
Создать заявку в Intradesk.
attachments список вложений с полем ``intradesk_meta`` (заполняется
через upload_attachments с target="Description" перед вызовом этой функции).
Возвращает ответ API (dict с полями id, tasknumber и др.) или None при ошибке.
Кэш клиента сбрасывается вызывающей стороной через invalidate_client_cache.
"""
@ -512,14 +689,16 @@ async def create_intradesk_task(
logger.warning("create_intradesk_task: INTRADESK_CHANGES_API_KEY не задан")
return None
full_desc = description
if attachment_urls:
full_desc += "\n\nВложения:\n" + "\n".join(attachment_urls)
blocks: dict[str, str] = {
"name": json.dumps({"value": name}, ensure_ascii=False),
"description": json.dumps({"value": full_desc}, ensure_ascii=False),
"description": json.dumps({"value": description}, 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,
)
if client_intradesk_id is not None and contact_person_id is not None:
blocks["initiator"] = json.dumps(
{"value": {"groupid": client_intradesk_id, "userid": contact_person_id}},

View file

@ -6,6 +6,7 @@ setup_logging() # до всех остальных импортов
from database.connection import init_db
from bot.setup import create_bot_and_dispatcher
from bot.notifier import run_notifier
from admin.app import create_admin_app
from maxapi.types.command import BotCommand
import config
@ -50,6 +51,7 @@ async def main() -> None:
await asyncio.gather(
dp.start_polling(bot, skip_updates=True),
server.serve(),
run_notifier(bot),
)

View file

@ -1,6 +1,3 @@
echo [INFO] Запуск... Admin-панель: http://localhost:8080/admin/
echo.
python main.py
if %ERRORLEVEL% neq 0 (