работа с файлами
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.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.states import TicketStates
|
from bot.states import TicketStates
|
||||||
from database.connection import get_db
|
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.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
|
from external.intradesk_api import create_intradesk_task, invalidate_client_cache, upload_attachments
|
||||||
|
|
||||||
logger = get_logger("bot.handlers.create_ticket")
|
logger = get_logger("bot.handlers.create_ticket")
|
||||||
|
|
||||||
|
|
@ -188,7 +189,7 @@ async def cb_create_ticket(
|
||||||
text=(
|
text=(
|
||||||
"📝 Опишите вашу проблему.\n\n"
|
"📝 Опишите вашу проблему.\n\n"
|
||||||
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
|
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
|
||||||
"Когда закончите — нажмите кнопку «Готово»."
|
"Когда закончите — нажмите «👁 Перейти к предпросмотру»."
|
||||||
),
|
),
|
||||||
attachments=[done_ticket_kb()],
|
attachments=[done_ticket_kb()],
|
||||||
)
|
)
|
||||||
|
|
@ -207,9 +208,41 @@ 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 ""
|
||||||
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)
|
await context.set_state(TicketStates.collecting_attachments)
|
||||||
|
|
||||||
mid = await bot_send(
|
mid = await bot_send(
|
||||||
|
|
@ -218,7 +251,7 @@ async def on_ticket_description(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text=(
|
text=(
|
||||||
"✏️ Описание принято. Вы можете добавить ещё файлы "
|
"✏️ Описание принято. Вы можете добавить ещё файлы "
|
||||||
"или нажмите «Готово» для отправки заявки."
|
"или нажмите «👁 Перейти к предпросмотру»."
|
||||||
),
|
),
|
||||||
attachments=[done_ticket_kb()],
|
attachments=[done_ticket_kb()],
|
||||||
)
|
)
|
||||||
|
|
@ -313,7 +346,7 @@ async def cb_edit_ticket(
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
text=(
|
text=(
|
||||||
"✏️ Продолжайте добавлять описание или вложения.\n"
|
"✏️ Продолжайте добавлять описание или вложения.\n"
|
||||||
"Нажмите «Готово» когда будете готовы."
|
"Нажмите «👁 Перейти к предпросмотру» когда будете готовы."
|
||||||
),
|
),
|
||||||
attachments=[done_ticket_kb()],
|
attachments=[done_ticket_kb()],
|
||||||
)
|
)
|
||||||
|
|
@ -324,7 +357,11 @@ async def cb_edit_ticket(
|
||||||
|
|
||||||
@router.message_callback(
|
@router.message_callback(
|
||||||
F.callback.payload == "cancel_ticket",
|
F.callback.payload == "cancel_ticket",
|
||||||
states=TicketStates.confirming,
|
states=[
|
||||||
|
TicketStates.waiting_description,
|
||||||
|
TicketStates.collecting_attachments,
|
||||||
|
TicketStates.confirming,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
async def cb_cancel_ticket(
|
async def cb_cancel_ticket(
|
||||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
|
|
@ -340,13 +377,14 @@ async def cb_cancel_ticket(
|
||||||
if chat_id is None:
|
if chat_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
await bot_send(
|
menu_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=await build_main_menu_text(bot_user),
|
text=await build_main_menu_text(bot_user),
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
|
await nav_set(context, menu_mid)
|
||||||
|
|
||||||
|
|
||||||
# ── Шаг 5а: "Подтвердить" → создание ─────────────────────────────────────────
|
# ── Шаг 5а: "Подтвердить" → создание ─────────────────────────────────────────
|
||||||
|
|
@ -373,9 +411,12 @@ async def cb_confirm_ticket(
|
||||||
if chat_id is None:
|
if chat_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
#await event.answer(notification="Отправляем заявку…")
|
|
||||||
await event.message.answer("⏳ Отправляем заявку…")
|
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 = await get_organization(bot_user["org_id"])
|
||||||
org_name = org["name"] if org else None
|
org_name = org["name"] if org else None
|
||||||
|
|
@ -407,14 +448,13 @@ 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,
|
||||||
attachment_urls=att_urls or None,
|
attachments=pending_atts or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
intradesk_number: int | None = None
|
intradesk_number: int | None = None
|
||||||
|
|
@ -455,10 +495,11 @@ async def cb_confirm_ticket(
|
||||||
text="\n".join(lines),
|
text="\n".join(lines),
|
||||||
)
|
)
|
||||||
|
|
||||||
await bot_send(
|
menu_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=await build_main_menu_text(bot_user),
|
text=await build_main_menu_text(bot_user),
|
||||||
attachments=[main_menu_kb()],
|
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 maxapi.types.updates.message_callback import MessageCallback
|
||||||
|
|
||||||
from bot.keyboards import main_menu_kb
|
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 database.queries.organizations import get_organization
|
||||||
from external.intradesk_api import count_client_open_tasks, get_client_last_comment
|
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:
|
if len(ctext) > 120:
|
||||||
ctext = ctext[:120] + "…"
|
ctext = ctext[:120] + "…"
|
||||||
raw_date = last_comment.get("date") or ""
|
raw_date = last_comment.get("date") or ""
|
||||||
# raw_date приходит как "2026-03-26 11:54"
|
|
||||||
date_part = raw_date[:10]
|
date_part = raw_date[:10]
|
||||||
time_part = raw_date[11:16] if len(raw_date) >= 16 else ""
|
time_part = raw_date[11:16] if len(raw_date) >= 16 else ""
|
||||||
if len(date_part) == 10:
|
if len(date_part) == 10:
|
||||||
|
|
@ -86,11 +85,23 @@ async def cb_main_menu(
|
||||||
await context.set_state(None)
|
await context.set_state(None)
|
||||||
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="⏳ Загружаем меню...",
|
||||||
|
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(
|
|
||||||
event,
|
await nav_finish(
|
||||||
|
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,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from maxapi.filters.command import Command
|
||||||
|
|
||||||
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.states import AuthStates
|
from bot.states import AuthStates
|
||||||
|
|
||||||
|
|
@ -70,13 +71,14 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
|
||||||
await context.set_state(None)
|
await context.set_state(None)
|
||||||
from bot.handlers.menu import build_main_menu_text
|
from bot.handlers.menu import build_main_menu_text
|
||||||
menu_text = "👋 С возвращением!\n\n" + await build_main_menu_text(bot_user)
|
menu_text = "👋 С возвращением!\n\n" + await build_main_menu_text(bot_user)
|
||||||
await bot_send(
|
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=menu_text,
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
|
await nav_set(context, mid)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Новый пользователь — запрашиваем контакт
|
# Новый пользователь — запрашиваем контакт
|
||||||
|
|
@ -146,13 +148,14 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
|
||||||
await context.set_state(None)
|
await context.set_state(None)
|
||||||
from bot.handlers.menu import build_main_menu_text
|
from bot.handlers.menu import build_main_menu_text
|
||||||
menu_text = await build_main_menu_text(bot_user)
|
menu_text = await build_main_menu_text(bot_user)
|
||||||
await bot_send(
|
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=menu_text,
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
|
await nav_set(context, mid)
|
||||||
|
|
||||||
|
|
||||||
@router.message_created(ContactFilter(), states=AuthStates.waiting_contact)
|
@router.message_created(ContactFilter(), states=AuthStates.waiting_contact)
|
||||||
|
|
@ -228,16 +231,19 @@ async def on_contact_shared(
|
||||||
)
|
)
|
||||||
await context.set_state(None)
|
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 = "✅ Вы успешно авторизованы!\n\n" + await build_main_menu_text(bot_user)
|
menu_text = "✅ Вы успешно авторизованы!\n\n" + await build_main_menu_text(bot_user)
|
||||||
|
|
||||||
await bot_send(
|
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=menu_text,
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
|
await nav_set(context, mid)
|
||||||
|
|
||||||
|
|
||||||
# ── /menu ─────────────────────────────────────────────────────────────────────
|
# ── /menu ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -258,13 +264,14 @@ async def on_menu_command(event: MessageCreated, context: BaseContext) -> None:
|
||||||
await context.set_state(None)
|
await context.set_state(None)
|
||||||
|
|
||||||
from bot.handlers.menu import build_main_menu_text
|
from bot.handlers.menu import build_main_menu_text
|
||||||
await bot_send(
|
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=await build_main_menu_text(bot_user),
|
text=await build_main_menu_text(bot_user),
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
|
await nav_set(context, mid)
|
||||||
|
|
||||||
|
|
||||||
# ── /cancel ───────────────────────────────────────────────────────────────────
|
# ── /cancel ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -300,10 +307,11 @@ async def on_cancel_command(event: MessageCreated, context: BaseContext) -> None
|
||||||
await context.set_state(None)
|
await context.set_state(None)
|
||||||
|
|
||||||
from bot.handlers.menu import build_main_menu_text
|
from bot.handlers.menu import build_main_menu_text
|
||||||
await bot_send(
|
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=cancel_text + await build_main_menu_text(bot_user),
|
text=cancel_text + await build_main_menu_text(bot_user),
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
|
await nav_set(context, mid)
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,10 @@ from maxapi.types.updates.message_callback import MessageCallback
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from bot.filters import PayloadStartsWith
|
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.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 bot.states import CommentStates
|
||||||
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
|
||||||
|
|
@ -34,6 +36,7 @@ from external.intradesk_api import (
|
||||||
get_task_last_comment,
|
get_task_last_comment,
|
||||||
invalidate_client_cache,
|
invalidate_client_cache,
|
||||||
invalidate_task_comment,
|
invalidate_task_comment,
|
||||||
|
upload_attachments,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = Router(router_id="view_tickets")
|
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)
|
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(
|
async def _load_and_show(
|
||||||
event: MessageCallback,
|
event: MessageCallback,
|
||||||
bot_user: dict,
|
bot_user: dict,
|
||||||
page: int,
|
page: int,
|
||||||
context: BaseContext,
|
context: BaseContext,
|
||||||
|
nav_mid: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Загрузить страницу заявок из API и показать.
|
Загрузить страницу заявок из API и показать.
|
||||||
|
|
||||||
Сразу редактирует сообщение на «⏳ Загрузка...», затем делает запрос к API.
|
nav_mid — уже отображённый индикатор загрузки (из nav_prepare).
|
||||||
Перед рендером проверяет load_token — если пользователь успел уйти
|
Если None — отправляет новое сообщение самостоятельно.
|
||||||
в другой флоу, контекст изменится и мы тихо выходим.
|
Редактирует nav_mid результатом; перед рендером проверяет load_token.
|
||||||
"""
|
"""
|
||||||
org_id = bot_user.get("org_id")
|
org_id = bot_user.get("org_id")
|
||||||
intradesk_client_id: int | None = None
|
intradesk_client_id: int | None = None
|
||||||
|
|
@ -121,27 +135,30 @@ async def _load_and_show(
|
||||||
org = await get_organization(org_id)
|
org = await get_organization(org_id)
|
||||||
intradesk_client_id = org.get("intradesk_id") if org else None
|
intradesk_client_id = org.get("intradesk_id") if org else None
|
||||||
|
|
||||||
# Уникальный токен для этого запроса
|
|
||||||
token = str(time.monotonic_ns())
|
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
|
chat_id = event.message.recipient.chat_id if event.message else None
|
||||||
|
|
||||||
# Записываем токен в контекст и сразу показываем загрузку
|
# Если nav_mid не задан — отправляем loading сами
|
||||||
await context.set_data({"load_token": token})
|
if not nav_mid:
|
||||||
await bot_reply(
|
nav_mid = await bot_send(
|
||||||
event,
|
event.bot,
|
||||||
text="⏳ Загружаем список заявок...",
|
chat_id=chat_id,
|
||||||
attachments=None,
|
user_id=bot_user["max_user_id"],
|
||||||
user_id=bot_user["max_user_id"],
|
text="⏳ Загружаем список заявок...",
|
||||||
chat_id=chat_id,
|
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:
|
if not intradesk_client_id:
|
||||||
# Проверяем токен перед рендером
|
|
||||||
data = await context.get_data()
|
data = await context.get_data()
|
||||||
if data.get("load_token") != token:
|
if data.get("load_token") != token:
|
||||||
|
await _delete_msg(event.bot, nav_mid)
|
||||||
return
|
return
|
||||||
await _edit_or_send(event.bot, mid, chat_id,
|
await _edit_or_send(event.bot, nav_mid, chat_id,
|
||||||
text="⚠️ Ваша организация не привязана к Intradesk. Обратитесь к администратору.",
|
text="⚠️ Ваша организация не привязана к Intradesk. Обратитесь к администратору.",
|
||||||
attachments=[menu_only_kb()])
|
attachments=[menu_only_kb()])
|
||||||
return
|
return
|
||||||
|
|
@ -154,13 +171,15 @@ async def _load_and_show(
|
||||||
# Проверяем токен — не ушёл ли пользователь пока грузили
|
# Проверяем токен — не ушёл ли пользователь пока грузили
|
||||||
data = await context.get_data()
|
data = await context.get_data()
|
||||||
if data.get("load_token") != token:
|
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:
|
if not tasks:
|
||||||
await _edit_or_send(event.bot, mid, chat_id,
|
await _edit_or_send(event.bot, nav_mid, chat_id,
|
||||||
text="📭 Активных заявок нет.",
|
text="📭 Активных заявок нет.",
|
||||||
attachments=[menu_only_kb()])
|
attachments=[menu_only_kb()])
|
||||||
return
|
return
|
||||||
|
|
@ -168,7 +187,7 @@ async def _load_and_show(
|
||||||
from bot.keyboards import intradesk_tasks_list_kb
|
from bot.keyboards import intradesk_tasks_list_kb
|
||||||
text = _build_tasks_text(tasks, page, total)
|
text = _build_tasks_text(tasks, page, total)
|
||||||
kb = intradesk_tasks_list_kb(tasks, page=page, total=total, page_size=_PAGE_SIZE)
|
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:
|
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
|
from bot.handlers.create_ticket import cleanup_ticket_flow
|
||||||
await cleanup_ticket_flow(event.bot, context)
|
await cleanup_ticket_flow(event.bot, context)
|
||||||
await event.answer(notification="")
|
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:"))
|
@router.message_callback(PayloadStartsWith("itickets_page:"))
|
||||||
|
|
@ -206,7 +233,15 @@ 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
|
||||||
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:"))
|
@router.message_callback(PayloadStartsWith("iticket_detail:"))
|
||||||
|
|
@ -220,6 +255,7 @@ async def cb_iticket_detail(
|
||||||
return
|
return
|
||||||
|
|
||||||
await event.answer(notification="")
|
await event.answer(notification="")
|
||||||
|
chat_id = event.message.recipient.chat_id if event.message else None
|
||||||
|
|
||||||
# Если пришли сюда из комментарийного флоу (кнопка «Назад») —
|
# Если пришли сюда из комментарийного флоу (кнопка «Назад») —
|
||||||
# удаляем промпт-сообщение и сбрасываем state, сохраняя кэш задач.
|
# удаляем промпт-сообщение и сбрасываем state, сохраняя кэш задач.
|
||||||
|
|
@ -227,28 +263,32 @@ async def cb_iticket_detail(
|
||||||
if state == CommentStates.waiting_text:
|
if state == CommentStates.waiting_text:
|
||||||
d = await context.get_data()
|
d = await context.get_data()
|
||||||
for mid in (d.get("flow_message_ids") or []):
|
for mid in (d.get("flow_message_ids") or []):
|
||||||
try:
|
await _delete_msg(event.bot, mid)
|
||||||
await event.bot.delete_message(message_id=mid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await context.set_state(None)
|
await context.set_state(None)
|
||||||
|
# Сохраняем кэш задач, но сбрасываем nav_mid (промпт уже удалён)
|
||||||
await context.set_data({
|
await context.set_data({
|
||||||
"tasks": d.get("tasks"),
|
"tasks": d.get("tasks"),
|
||||||
"page": d.get("page"),
|
"page": d.get("page"),
|
||||||
"total": d.get("total"),
|
"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()
|
data = await context.get_data()
|
||||||
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
|
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
|
||||||
|
|
||||||
if task is None:
|
if task is None:
|
||||||
await bot_reply(
|
await nav_finish(
|
||||||
event,
|
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
||||||
text="⚠️ Заявка не найдена. Вернитесь к списку.",
|
text="⚠️ Заявка не найдена. Вернитесь к списку.",
|
||||||
attachments=[iticket_detail_kb()],
|
attachments=[iticket_detail_kb()],
|
||||||
user_id=bot_user["max_user_id"],
|
|
||||||
chat_id=event.message.recipient.chat_id if event.message else None,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -280,19 +320,17 @@ async def cb_iticket_detail(
|
||||||
ctext = ctext[:300] + "…"
|
ctext = ctext[:300] + "…"
|
||||||
lines += [
|
lines += [
|
||||||
"",
|
"",
|
||||||
f"─" * 16,
|
"─" * 16,
|
||||||
f"{c['date']} — {c['author']}",
|
f"{c['date']} — {c['author']}",
|
||||||
ctext,
|
ctext,
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
lines += ["", "💬 Комментариев нет"]
|
lines += ["", "💬 Комментариев нет"]
|
||||||
|
|
||||||
await bot_reply(
|
await nav_finish(
|
||||||
event,
|
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
||||||
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(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 = 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
|
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:
|
if not task_number:
|
||||||
await bot_reply(
|
nav_mid = await nav_prepare(
|
||||||
event,
|
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="⚠️ Не удалось определить номер заявки. Вернитесь к списку.",
|
text="⚠️ Не удалось определить номер заявки. Вернитесь к списку.",
|
||||||
attachments=[iticket_detail_kb()],
|
attachments=[iticket_detail_kb()],
|
||||||
user_id=bot_user["max_user_id"],
|
|
||||||
chat_id=event.message.recipient.chat_id if event.message else None,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
nav_mid = await nav_prepare(
|
||||||
|
event, context,
|
||||||
|
loading_text="⏳ Закрываем заявку...",
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=bot_user["max_user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
# Получаем contact_person_id
|
# Получаем contact_person_id
|
||||||
intradesk_id: int | None = None
|
intradesk_id: int | None = None
|
||||||
contact_person_id: int | None = None
|
contact_person_id: int | None = None
|
||||||
|
|
@ -345,20 +396,16 @@ async def cb_close_ticket(
|
||||||
if intradesk_id:
|
if intradesk_id:
|
||||||
invalidate_client_cache(intradesk_id)
|
invalidate_client_cache(intradesk_id)
|
||||||
num_str = f" #{task_number}" if task_number else ""
|
num_str = f" #{task_number}" if task_number else ""
|
||||||
await bot_reply(
|
await nav_finish(
|
||||||
event,
|
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
||||||
text=f"✅ Заявка{num_str} закрыта.",
|
text=f"✅ Заявка{num_str} закрыта.",
|
||||||
attachments=[menu_only_kb()],
|
attachments=[menu_only_kb()],
|
||||||
user_id=bot_user["max_user_id"],
|
|
||||||
chat_id=event.message.recipient.chat_id if event.message else None,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await bot_reply(
|
await nav_finish(
|
||||||
event,
|
event.bot, context, nav_mid, chat_id, bot_user["max_user_id"],
|
||||||
text="⚠️ Не удалось закрыть заявку. Попробуйте позже.",
|
text="⚠️ Не удалось закрыть заявку. Попробуйте позже.",
|
||||||
attachments=[iticket_detail_kb(task_id=task_id, can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
|
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"],
|
user_id=bot_user["max_user_id"],
|
||||||
text=(
|
text=(
|
||||||
f"💬 Напишите комментарий{num_str}:\n\n"
|
f"💬 Напишите комментарий{num_str}:\n\n"
|
||||||
"Отправьте текстовое сообщение или нажмите «← Назад к заявке» для отмены."
|
"Можно отправить текст, файл, фото или голосовое сообщение.\n"
|
||||||
|
"Нажмите «← Назад к заявке» для отмены."
|
||||||
),
|
),
|
||||||
attachments=[comment_input_kb(task_id)],
|
attachments=[comment_input_kb(task_id)],
|
||||||
)
|
)
|
||||||
|
|
@ -424,12 +472,14 @@ async def on_comment_text(
|
||||||
|
|
||||||
body = event.message.body
|
body = event.message.body
|
||||||
text = (body.text or "").strip() if body else ""
|
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(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text="❗ Комментарий не может быть пустым. Напишите текст:",
|
text="❗ Комментарий не может быть пустым. Напишите текст или прикрепите файл:",
|
||||||
attachments=[menu_only_kb()],
|
attachments=[menu_only_kb()],
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -453,10 +503,19 @@ async def on_comment_text(
|
||||||
db, intradesk_id, bot_user["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 "📎 Файл прикреплён"
|
||||||
|
|
||||||
ok = await add_task_comment(
|
ok = await add_task_comment(
|
||||||
task_number=task_number,
|
task_number=task_number,
|
||||||
text=text,
|
text=comment_text,
|
||||||
contact_person_id=contact_person_id,
|
contact_person_id=contact_person_id,
|
||||||
|
attachments=file_atts or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
if ok:
|
if ok:
|
||||||
|
|
@ -484,10 +543,11 @@ async def on_comment_text(
|
||||||
text="⚠️ Не удалось отправить комментарий. Попробуйте позже.",
|
text="⚠️ Не удалось отправить комментарий. Попробуйте позже.",
|
||||||
)
|
)
|
||||||
|
|
||||||
await bot_send(
|
menu_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=await build_main_menu_text(bot_user),
|
text=await build_main_menu_text(bot_user),
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
|
await nav_set(context, menu_mid)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ def done_ticket_kb() -> Attachment:
|
||||||
return (
|
return (
|
||||||
InlineKeyboardBuilder()
|
InlineKeyboardBuilder()
|
||||||
.row(CallbackButton(text="✅ Готово", payload="done_ticket"))
|
.row(CallbackButton(text="✅ Готово", payload="done_ticket"))
|
||||||
|
.row(CallbackButton(text="❌ Отменить создание", payload="cancel_ticket"))
|
||||||
.as_markup()
|
.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:
|
def ticket_detail_kb(page: int = 0) -> Attachment:
|
||||||
return (
|
return (
|
||||||
InlineKeyboardBuilder()
|
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", "")
|
INTRADESK_DEFAULT_TASK_TYPE: str = os.environ.get("INTRADESK_DEFAULT_TASK_TYPE", "")
|
||||||
# ID статуса «Выполнена» для кнопки «Закрыть заявку» (Настройки → Справочники → Статусы → ID)
|
# ID статуса «Выполнена» для кнопки «Закрыть заявку» (Настройки → Справочники → Статусы → ID)
|
||||||
INTRADESK_CLOSE_STATUS_ID: str = os.environ.get("INTRADESK_CLOSE_STATUS_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,6 +45,21 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> aiosqlite.Connection:
|
def get_db() -> aiosqlite.Connection:
|
||||||
if _db is None:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
@ -91,6 +92,127 @@ def invalidate_task_comment(task_id: int) -> None:
|
||||||
logger.debug("cache invalidated for task_id=%s", 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-функции ───────────────────────────────────────────────────────────────
|
# ── API-функции ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def get_task_last_comment(task_id: int) -> dict | None:
|
async def get_task_last_comment(task_id: int) -> dict | None:
|
||||||
|
|
@ -371,6 +493,48 @@ async def get_client_open_tasks(
|
||||||
return result
|
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:
|
def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None:
|
||||||
"""Извлечь последний комментарий из поля lifetime задачи."""
|
"""Извлечь последний комментарий из поля lifetime задачи."""
|
||||||
if not lifetime:
|
if not lifetime:
|
||||||
|
|
@ -391,10 +555,14 @@ async def add_task_comment(
|
||||||
task_number: int,
|
task_number: int,
|
||||||
text: str,
|
text: str,
|
||||||
contact_person_id: int | None = None,
|
contact_person_id: int | None = None,
|
||||||
|
attachments: list[dict] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Добавить комментарий к заявке (PUT /changes/v3/tasks).
|
Добавить комментарий к заявке (PUT /changes/v3/tasks).
|
||||||
|
|
||||||
|
attachments — список вложений с полем ``intradesk_meta`` (заполняется
|
||||||
|
через upload_attachments с target="Comment" перед вызовом этой функции).
|
||||||
|
|
||||||
Возвращает True при успехе, False при ошибке.
|
Возвращает True при успехе, False при ошибке.
|
||||||
Кэш задачи сбрасывается вызывающей стороной через invalidate_task_comment.
|
Кэш задачи сбрасывается вызывающей стороной через invalidate_task_comment.
|
||||||
"""
|
"""
|
||||||
|
|
@ -406,6 +574,12 @@ async def add_task_comment(
|
||||||
blocks: dict[str, str] = {
|
blocks: dict[str, str] = {
|
||||||
"comment": json.dumps({"value": text}, ensure_ascii=False),
|
"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"}
|
body = {"number": task_number, "blocks": blocks, "Channel": "api"}
|
||||||
url = f"{config.INTRADESK_BASE_URL.rstrip('/')}/changes/v3/tasks?ApiKey={key}"
|
url = f"{config.INTRADESK_BASE_URL.rstrip('/')}/changes/v3/tasks?ApiKey={key}"
|
||||||
if contact_person_id is not None:
|
if contact_person_id is not None:
|
||||||
|
|
@ -499,11 +673,14 @@ async def create_intradesk_task(
|
||||||
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,
|
||||||
attachment_urls: list[str] | None = None,
|
attachments: list[dict] | 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.
|
Кэш клиента сбрасывается вызывающей стороной через invalidate_client_cache.
|
||||||
"""
|
"""
|
||||||
|
|
@ -512,14 +689,16 @@ async def create_intradesk_task(
|
||||||
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": 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:
|
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}},
|
||||||
|
|
|
||||||
2
main.py
2
main.py
|
|
@ -6,6 +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
|
from maxapi.types.command import BotCommand
|
||||||
import config
|
import config
|
||||||
|
|
@ -50,6 +51,7 @@ 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
3
run.bat
3
run.bat
|
|
@ -1,6 +1,3 @@
|
||||||
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