работа с файлами
This commit is contained in:
parent
96233afa87
commit
2693dc496d
16 changed files with 754 additions and 85 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
110
bot/nav_helper.py
Normal 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
173
bot/notifier.py
Normal 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)
|
||||
|
|
@ -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 исполнителей через запятую (доп. фильтр)
|
||||
|
|
|
|||
BIN
data/support.db
BIN
data/support.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
60
database/queries/task_watch.py
Normal file
60
database/queries/task_watch.py
Normal 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()
|
||||
191
external/intradesk_api.py
vendored
191
external/intradesk_api.py
vendored
|
|
@ -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}},
|
||||
|
|
|
|||
2
main.py
2
main.py
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
3
run.bat
3
run.bat
|
|
@ -1,6 +1,3 @@
|
|||
echo [INFO] Запуск... Admin-панель: http://localhost:8080/admin/
|
||||
echo.
|
||||
|
||||
python main.py
|
||||
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue