diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 787b5db..96843cb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,12 @@ { "permissions": { "allow": [ - "Bash(python -c \":*)" + "Bash(python -c \":*)", + "Bash(python -c \"from bot.handlers.view_tickets import router; from bot.keyboards import intradesk_tasks_list_kb; from database.queries.intradesk import get_tasks_for_local_org, count_tasks_for_local_org, get_task_for_local_org; print\\(''OK''\\)\")", + "Bash(python -c \"import maxapi; import inspect; members = [m for m in dir\\(maxapi\\) if not m.startswith\\(''_''\\)]; print\\(members\\)\")", + "Bash(python -c \"from maxapi import Bot; methods = [m for m in dir\\(Bot\\) if not m.startswith\\(''_''\\)]; print\\(methods\\)\")", + "Bash(python -c \"from maxapi import Bot; import inspect; print\\(inspect.signature\\(Bot.edit_message\\)\\)\")", + "Bash(python -c \"from maxapi.types.updates.message_callback import MessageCallback; print\\([m for m in dir\\(MessageCallback\\) if not m.startswith\\(''_''\\)]\\)\")" ] } } diff --git a/.env b/.env index 19aefcc..a48ce5e 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -MAX_BOT_TOKEN=f9LHodD0cOJHqFlGFuM7t9dFSt0jA36NxLc5aSP0CwDuCyWzssGKsFwuP4ymzoP64uqe6Ivl4_bEWKAOl6MI +MAX_BOT_TOKEN=f9LHodD0cOKyEOxn1KcWiRbcZSn9HqLPMd6RlI_M0-YMvNNwXyLANk65rHpu8dhatDoRO6YXJ-9-72P4R-cJ # При локальном запуске (python main.py): DB_PATH=data/support.db @@ -17,3 +17,9 @@ INTRADESK_API_KEY=4842AA91805F4630853EF42D74 INTRADESK_TASKS_API_KEY=26221B839B6643E68345FD26BF INTRADESK_EXECUTOR_GROUP=243025 INTRADESK_EXECUTORS=2227907,525111,631481,2222953 +# Ключ для /changes/v3/tasks (создание и изменение заявок) +INTRADESK_CHANGES_API_KEY=26221B839B6643E68345FD26BF +# Путь сервиса (Настройки → Сервисы → карточка → «Путь сервиса»), напр: 84681/84683/ +INTRADESK_DEFAULT_SERVICE=84681/84683/ +# ID типа заявки (Настройки → Заявки → Типы заявок → ID) +INTRADESK_DEFAULT_TASK_TYPE=21026 diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index a362cf7..6fc1c9c 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -23,12 +23,15 @@ from maxapi.types.updates.message_created import MessageCreated from bot.keyboards import done_ticket_kb, main_menu_kb from bot.logging_setup import get_logger from bot.send_helper import bot_send +from database.connection import get_db +from database.queries.intradesk import get_intradesk_contact_id_for_phone +from database.queries.organizations import get_organization from bot.states import TicketStates logger = get_logger("bot.handlers.create_ticket") from database.queries.ticket_attachments import add_attachment -from database.queries.tickets import create_ticket -import external.client as ext +from database.queries.tickets import create_ticket, set_intradesk_task_id +from external.intradesk_api import create_intradesk_task router = Router(router_id="create_ticket") @@ -164,13 +167,13 @@ async def cb_done_ticket( created_by=bot_user["id"], description=description, ) + pending_atts = data.get("pending_attachments", []) logger.info( "✅ ticket created | ticket_id=%s user_id=%s org_id=%s attachments=%s", - ticket_id, bot_user["max_user_id"], bot_user["org_id"], - len(data.get("pending_attachments", [])), + ticket_id, bot_user["max_user_id"], bot_user["org_id"], len(pending_atts), ) - for att in data.get("pending_attachments", []): + for att in pending_atts: await add_attachment( ticket_id=ticket_id, attachment_type=att["type"], @@ -178,22 +181,63 @@ async def cb_done_ticket( ) await context.clear() - await event.answer(notification="Заявка создана!") + + # ── Отправка в Intradesk ────────────────────────────────────────────────── + org = await get_organization(bot_user["org_id"]) + org_name = org["name"] if org else None + intradesk_id: int | None = org.get("intradesk_id") if org else None + + # Находим id контактного лица в Intradesk по телефону пользователя + contact_person_id: int | None = None + if intradesk_id and bot_user.get("phone"): + db = get_db() + contact_person_id = await get_intradesk_contact_id_for_phone( + db, intradesk_id, bot_user["phone"] + ) + + # Ссылки на вложения (Max CDN) — добавятся в описание задачи + att_urls = [a["url"] for a in pending_atts if a.get("url")] + + async def _create_in_intradesk(): + result = await create_intradesk_task( + name=description[:150], + description=description, + client_intradesk_id=intradesk_id, + contact_person_id=contact_person_id, + attachment_urls=att_urls or None, + ) + if result: + iid = result.get("id") + if iid: + await set_intradesk_task_id(ticket_id, int(iid)) + logger.info("Intradesk task created: id=%s for ticket_id=%s", iid, ticket_id) + + asyncio.create_task(_create_in_intradesk()) + + # ── Подтверждение пользователю ──────────────────────────────────────────── + att_count = len(pending_atts) + confirm_text = ( + f"✅ Заявка успешно создана!\n" + f"Описание: {description[:200]}{'…' if len(description) > 200 else ''}" + ) + if att_count: + confirm_text += f"\nВложений: {att_count}" + confirm_text += "\n\nНаши специалисты свяжутся с вами в ближайшее время." + await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], - text=( - f"✅ Заявка №{ticket_id} успешно создана!\n" - "Наши специалисты свяжутся с вами в ближайшее время." - ), - attachments=[main_menu_kb()], + text=confirm_text, ) - # Fire-and-forget: отправка во внешнюю систему - async def _sync_external(): - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, ext.sync_ticket, ticket_id) + menu_text = f"🏠 {org_name}\nВыберите действие:" if org_name else "🏠 Главное меню\nВыберите действие:" - asyncio.create_task(_sync_external()) + await bot_send( + event.bot, + chat_id=chat_id, + user_id=bot_user["max_user_id"], + text=menu_text, + attachments=[main_menu_kb()], + ) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 5059aa3..c0af9b0 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -5,11 +5,25 @@ from maxapi.types.updates.message_callback import MessageCallback from maxapi.context import BaseContext from bot.keyboards import main_menu_kb -from bot.send_helper import bot_send +from bot.send_helper import bot_reply +from database.queries.organizations import get_organization router = Router(router_id="menu") +async def _main_menu_text(bot_user: dict) -> str: + org_id = bot_user.get("org_id") + if org_id: + org = await get_organization(org_id) + org_name = org["name"] if org else None + else: + org_name = None + + if org_name: + return f"🏠 {org_name}\nВыберите действие:" + return "🏠 Главное меню\nВыберите действие:" + + @router.message_callback(F.callback.payload == "main_menu") async def cb_main_menu( event: MessageCallback, context: BaseContext, bot_user: dict @@ -17,14 +31,11 @@ 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 - if chat_id is None: - return - - await bot_send( - event.bot, - chat_id=chat_id, - user_id=bot_user["max_user_id"], - text="Главное меню:", + text = await _main_menu_text(bot_user) + await bot_reply( + event, + 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, ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 6437fe7..09a796b 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -30,6 +30,7 @@ from database.queries.bot_users import ( ) from database.queries.org_users import find_by_phone from database.queries.message_log import log_message +from database.queries.organizations import get_organization router = Router(router_id="start") @@ -223,10 +224,18 @@ async def on_contact_shared( ) await context.set_state(None) + org = await get_organization(org_user["org_id"]) + org_name = org["name"] if org else None + menu_text = ( + f"✅ Вы успешно авторизованы!\n\n🏠 {org_name}\nВыберите действие:" + if org_name + else "✅ Вы успешно авторизованы! Выберите действие:" + ) + await bot_send( event.bot, chat_id=chat_id, user_id=user_id, - text="✅ Вы успешно авторизованы! Выберите действие:", + text=menu_text, attachments=[main_menu_kb()], ) diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index 8393128..6fb12bb 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -1,65 +1,67 @@ """ -Обработчики просмотра заявок. +Обработчики просмотра заявок (Intradesk). -Навигация полностью через callback payload — FSM-состояния не нужны. - "view_tickets" → список, страница 0 - "tickets_page:{n}" → список, страница n - "ticket_detail:{id}" → карточка заявки - "main_menu" → в menu.py +Навигация через callback payload — все переходы редактируют текущее сообщение: + "view_tickets" → список, страница 0 + "itickets_page:{n}" → список, страница n + "iticket_detail:{id}" → карточка задачи + "main_menu" → в menu.py """ +import re + from maxapi.context import BaseContext from maxapi.dispatcher import Router from maxapi.filters import F from maxapi.types.updates.message_callback import MessageCallback from bot.filters import PayloadStartsWith -from bot.keyboards import ticket_detail_kb, tickets_list_kb -from bot.send_helper import bot_send -from database.queries.ticket_attachments import get_attachments -from database.queries.tickets import ( - PAGE_SIZE, - count_tickets_for_org, - get_ticket, - get_tickets_for_org, +from bot.keyboards import intradesk_tasks_list_kb, iticket_detail_kb +from bot.send_helper import bot_reply +from database.connection import get_db +from database.queries.intradesk import ( + count_tasks_for_local_org, + get_task_for_local_org, + get_tasks_for_local_org, ) +from external.intradesk_api import get_task_last_comment router = Router(router_id="view_tickets") -STATUS_LABELS = { - "open": "🔴 Открыта", - "in_progress": "🟡 В работе", - "closed": "🟢 Закрыта", -} +_PAGE_SIZE = 8 -async def _send_tickets_page( - event: MessageCallback, page: int, bot_user: dict -) -> None: - org_id = bot_user["org_id"] - tickets = await get_tickets_for_org(org_id, page=page) - total = await count_tickets_for_org(org_id) +def _status_icon(status_name: str) -> str: + sn = status_name.lower() + if "выполнен" in sn: + return "🟢" + if "работ" in sn: + return "🟡" + if "открыт" in sn: + return "🔴" + return "⚪" - chat_id = event.message.recipient.chat_id if event.message else None - if chat_id is None: - return - if not tickets: - await bot_send( - event.bot, - chat_id=chat_id, - user_id=bot_user["max_user_id"], - text="📭 У вашей организации пока нет заявок.", - attachments=[ticket_detail_kb(page=0)], - ) - return +def _priority_icon(priority_name: str) -> str: + pn = priority_name.lower() + if "критич" in pn: + return "🔴" + if "высок" in pn: + return "🟠" + if "средн" in pn: + return "🟡" + return "⚪" - await bot_send( - event.bot, - chat_id=chat_id, - user_id=bot_user["max_user_id"], - text=f"📋 Заявки организации (стр. {page + 1}):", - attachments=[tickets_list_kb(tickets, page=page, total=total)], - ) + +async def _tasks_page_text(org_id: int, page: int) -> tuple[str, list[dict], int]: + """Возвращает (текст, задачи, всего).""" + db = get_db() + tasks = await get_tasks_for_local_org(db, org_id, page=page, page_size=_PAGE_SIZE) + total = await count_tasks_for_local_org(db, org_id) + if not tasks: + return "📭 У вашей организации пока нет заявок.", [], 0 + pages = max(1, -(-total // _PAGE_SIZE)) + text = f"📋 Заявки — стр. {page + 1} из {pages} (всего: {total}):" + return text, tasks, total @router.message_callback(F.callback.payload == "view_tickets") @@ -67,11 +69,19 @@ async def cb_view_tickets( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: await event.answer(notification="") - await _send_tickets_page(event, page=0, bot_user=bot_user) + text, tasks, total = await _tasks_page_text(bot_user["org_id"], 0) + kb = intradesk_tasks_list_kb(tasks, page=0, total=total) if tasks else None + await bot_reply( + event, + text=text, + attachments=[kb] if kb else None, + user_id=bot_user["max_user_id"], + chat_id=event.message.recipient.chat_id if event.message else None, + ) -@router.message_callback(PayloadStartsWith("tickets_page:")) -async def cb_tickets_page( +@router.message_callback(PayloadStartsWith("itickets_page:")) +async def cb_itickets_page( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: await event.answer(notification="") @@ -79,63 +89,79 @@ async def cb_tickets_page( page = int(event.callback.payload.split(":")[1]) except (IndexError, ValueError): page = 0 - await _send_tickets_page(event, page=page, bot_user=bot_user) + text, tasks, total = await _tasks_page_text(bot_user["org_id"], page) + kb = intradesk_tasks_list_kb(tasks, page=page, total=total) if tasks else None + await bot_reply( + event, + text=text, + attachments=[kb] if kb else None, + user_id=bot_user["max_user_id"], + chat_id=event.message.recipient.chat_id if event.message else None, + ) -@router.message_callback(PayloadStartsWith("ticket_detail:")) -async def cb_ticket_detail( +@router.message_callback(PayloadStartsWith("iticket_detail:")) +async def cb_iticket_detail( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: try: - ticket_id = int(event.callback.payload.split(":")[1]) + task_id = int(event.callback.payload.split(":")[1]) except (IndexError, ValueError): await event.answer(notification="Ошибка") return - ticket = await get_ticket(ticket_id) + db = get_db() + task = await get_task_for_local_org(db, task_id, bot_user["org_id"]) - chat_id = event.message.recipient.chat_id if event.message else None - if chat_id is None: - return - - # Проверка доступа: заявка должна принадлежать той же организации - if ticket is None or ticket["org_id"] != bot_user["org_id"]: + if task is None: await event.answer(notification="Нет доступа") return await event.answer(notification="") - status_label = STATUS_LABELS.get(ticket["status"], ticket["status"]) - text = ( - f"📌 Заявка #{ticket['id']}\n" - f"Статус: {status_label}\n" - f"Создана: {ticket['created_at'][:16]}\n\n" - f"📄 Описание:\n{ticket['description']}" - ) - if ticket.get("last_comment"): - text += f"\n\n💬 Последний комментарий:\n{ticket['last_comment']}" + sicon = _status_icon(task.get("status_name") or "") + picon = _priority_icon(task.get("priority_name") or "") - # Вложения - attachments = await get_attachments(ticket_id) - if attachments: - att_lines = [] - for a in attachments: - icon = { - "AttachmentType.IMAGE": "🖼", - "AttachmentType.VIDEO": "🎬", - "AttachmentType.AUDIO": "🎙", - "AttachmentType.FILE": "📎", - }.get(a["attachment_type"], "📎") - line = f"{icon} {a['attachment_type'].split('.')[-1].capitalize()}" - if a.get("attachment_url"): - line += f": {a['attachment_url']}" - att_lines.append(line) - text += "\n\n📎 Вложения:\n" + "\n".join(att_lines) + num = task.get("task_number") or task["id"] + lines = [ + f"📌 Заявка #{num}", + f"Статус: {sicon} {task.get('status_name') or '—'}", + f"Приоритет: {picon} {task.get('priority_name') or '—'}", + ] + if task.get("service_name"): + lines.append(f"Сервис: {task['service_name']}") + if task.get("executor_name"): + lines.append(f"Исполнитель: {task['executor_name']}") + lines.append(f"Создана: {(task.get('created_at') or '')[:10] or '—'}") + lines.append(f"Обновлена: {(task.get('updated_at') or '')[:10] or '—'}") - await bot_send( - event.bot, - chat_id=chat_id, + if task.get("name"): + lines += ["", f"📄 {task['name']}"] + + if task.get("description"): + desc = re.sub(r"<[^>]+>", "", task["description"]).strip() + if desc: + if len(desc) > 800: + desc = desc[:800] + "…" + lines += ["", f"💬 {desc}"] + + # Последний комментарий — запрашиваем из Intradesk API + comment = await get_task_last_comment(task["id"]) + if comment and comment.get("text"): + ctext = comment["text"] + if len(ctext) > 500: + ctext = ctext[:500] + "…" + lines += [ + "", + f"💭 Последний комментарий ({comment['date']}):", + f" {ctext}", + f" — {comment['author']}", + ] + + await bot_reply( + event, + text="\n".join(lines), + attachments=[iticket_detail_kb()], user_id=bot_user["max_user_id"], - text=text, - attachments=[ticket_detail_kb(page=0)], + chat_id=event.message.recipient.chat_id if event.message else None, ) diff --git a/bot/keyboards.py b/bot/keyboards.py index a9fdc8e..185ef6b 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -10,7 +10,7 @@ def main_menu_kb() -> Attachment: return ( InlineKeyboardBuilder() .row(CallbackButton(text="📝 Создать заявку", payload="create_ticket")) - .row(CallbackButton(text="📋 Просмотреть заявки", payload="view_tickets")) + .row(CallbackButton(text="📋 Мои заявки", payload="view_tickets")) .as_markup() ) @@ -61,6 +61,53 @@ def tickets_list_kb( return builder.as_markup() +def intradesk_tasks_list_kb( + tasks: list[dict], page: int, total: int, page_size: int = 8 +) -> Attachment: + builder = InlineKeyboardBuilder() + for t in tasks: + name_preview = (t.get("name") or "")[:35] + if len(t.get("name") or "") > 35: + name_preview += "…" + sn = (t.get("status_name") or "").lower() + if "выполнен" in sn: + icon = "🟢" + elif "работ" in sn: + icon = "🟡" + elif "открыт" in sn: + icon = "🔴" + else: + icon = "⚪" + label = t.get("task_number") or t["id"] + builder.row( + CallbackButton( + text=f"#{label} {icon} {name_preview}", + payload=f"iticket_detail:{t['id']}", + ) + ) + + nav = [] + if page > 0: + nav.append(CallbackButton(text="← Пред.", payload=f"itickets_page:{page - 1}")) + if (page + 1) * page_size < total: + nav.append(CallbackButton(text="След. →", payload=f"itickets_page:{page + 1}")) + if nav: + builder.row(*nav) + + builder.row(CallbackButton(text="← Назад в меню", payload="main_menu")) + return builder.as_markup() + + +def iticket_detail_kb() -> Attachment: + """Клавиатура внутри карточки Intradesk-задачи.""" + return ( + InlineKeyboardBuilder() + .row(CallbackButton(text="← К списку заявок", payload="itickets_page:0")) + .row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) + .as_markup() + ) + + def ticket_detail_kb(page: int = 0) -> Attachment: return ( InlineKeyboardBuilder() diff --git a/bot/send_helper.py b/bot/send_helper.py index 64fc51c..e7e262e 100644 --- a/bot/send_helper.py +++ b/bot/send_helper.py @@ -1,4 +1,4 @@ -"""Обёртка над bot.send_message с автоматическим логированием исходящих.""" +"""Обёртки над bot.send_message / bot.edit_message с автоматическим логированием.""" from database.queries.message_log import log_message @@ -10,6 +10,7 @@ async def bot_send( text: str, attachments=None, ) -> None: + """Отправить новое сообщение.""" kw: dict = {"chat_id": chat_id, "text": text} if attachments is not None: kw["attachments"] = attachments @@ -21,3 +22,69 @@ async def bot_send( message_id=None, text=text, ) + + +async def bot_edit( + event, + *, + text: str, + attachments=None, +) -> bool: + """ + Редактировать сообщение, на которое нажата callback-кнопка. + + Возвращает True при успехе. При ошибке (сообщение удалено, слишком старое) + возвращает False — вызывающий код должен откатиться на bot_send. + """ + if event.message is None or event.message.body is None: + return False + mid = event.message.body.mid + chat_id = event.message.recipient.chat_id + user_id: int | None = getattr(event.callback, "user", None) + if user_id is not None: + user_id = getattr(user_id, "user_id", None) + try: + await event.bot.edit_message( + message_id=mid, + text=text, + attachments=attachments if attachments is not None else [], + ) + await log_message( + direction="out", + max_user_id=user_id, + max_chat_id=chat_id, + message_id=mid, + text=text, + ) + return True + except Exception: + return False + + +async def bot_reply( + event, + *, + text: str, + attachments=None, + user_id: int | None = None, + chat_id: int | None = None, +) -> None: + """ + Для callback-событий: пробует отредактировать текущее сообщение. + При неудаче отправляет новое. + + Использование в callback-обработчиках вместо bot_send — не создаёт спам. + """ + edited = await bot_edit(event, text=text, attachments=attachments) + if not edited: + _chat_id = chat_id + if _chat_id is None and event.message is not None: + _chat_id = event.message.recipient.chat_id + if _chat_id is not None: + await bot_send( + event.bot, + chat_id=_chat_id, + user_id=user_id, + text=text, + attachments=attachments, + ) diff --git a/config.py b/config.py index 95a12d1..ef27343 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,5 @@ import os +import sys from pathlib import Path from dotenv import load_dotenv @@ -6,6 +7,15 @@ load_dotenv() BOT_TOKEN: str = os.environ["MAX_BOT_TOKEN"] +# ── Dry-run режим ───────────────────────────────────────────────────────────── +# Включается флагом --dry-run при запуске или переменной DRY_RUN=true в .env. +# В этом режиме все записывающие вызовы к Intradesk НЕ выполняются — +# вместо этого их payload печатается в консоль. +DRY_RUN: bool = ( + "--dry-run" in sys.argv + or os.environ.get("DRY_RUN", "").lower() in ("1", "true", "yes") +) + _project_root = Path(__file__).parent _db_path_raw = os.environ.get("DB_PATH", "data/support.db") DB_PATH: str = str( @@ -20,8 +30,14 @@ ADMIN_PORT: int = int(os.environ.get("ADMIN_PORT", "8080")) INTRADESK_BASE_URL: str = os.environ.get("INTRADESK_BASE_URL", "https://apigw.intradesk.ru") # Ключ для /settings/odata (клиенты, пользователи) INTRADESK_API_KEY: str = os.environ.get("INTRADESK_API_KEY", "") -# Ключ для /tasklist/odata (заявки) +# Ключ для /tasklist/odata (чтение заявок) и /taskhistory (история/комментарии) INTRADESK_TASKS_API_KEY: str = os.environ.get("INTRADESK_TASKS_API_KEY", "") +# Ключ для /changes/v3/tasks (создание и изменение заявок) +INTRADESK_CHANGES_API_KEY: str = os.environ.get("INTRADESK_CHANGES_API_KEY", "") +# Путь сервиса для новых заявок (Настройки → Сервисы → «Путь сервиса», напр. "84681/84683/") +INTRADESK_DEFAULT_SERVICE: str = os.environ.get("INTRADESK_DEFAULT_SERVICE", "") +# ID типа заявки по умолчанию (Настройки → Заявки → Типы заявок → ID) +INTRADESK_DEFAULT_TASK_TYPE: str = os.environ.get("INTRADESK_DEFAULT_TASK_TYPE", "") # ID группы исполнителей для фильтрации заявок INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "") # Список ID исполнителей через запятую (доп. фильтр) diff --git a/data/support.db-shm b/data/support.db-shm index 5d200f0..79890f8 100644 Binary files a/data/support.db-shm and b/data/support.db-shm differ diff --git a/data/support.db-wal b/data/support.db-wal index 755161b..98a20f5 100644 Binary files a/data/support.db-wal and b/data/support.db-wal differ diff --git a/database/connection.py b/database/connection.py index e9dbc66..9eb248f 100644 --- a/database/connection.py +++ b/database/connection.py @@ -36,6 +36,15 @@ async def init_db() -> None: except Exception: pass # колонка уже существует + # Добавляем intradesk_task_id в tickets, если ещё нет + try: + await _db.execute( + "ALTER TABLE tickets ADD COLUMN intradesk_task_id INTEGER" + ) + await _db.commit() + except Exception: + pass # колонка уже существует + def get_db() -> aiosqlite.Connection: if _db is None: diff --git a/database/queries/intradesk.py b/database/queries/intradesk.py index ce2be00..18a5a30 100644 --- a/database/queries/intradesk.py +++ b/database/queries/intradesk.py @@ -285,6 +285,88 @@ async def get_last_sync( return dict(row) if row else None +# ── Tasks by local org_id ───────────────────────────────────────────────────── + +_BOT_PAGE_SIZE = 8 + + +async def get_tasks_for_local_org( + db: aiosqlite.Connection, + local_org_id: int, + page: int = 0, + page_size: int = _BOT_PAGE_SIZE, +) -> list[dict]: + """ + Вернуть intradesk_tasks для локальной организации (через organizations.intradesk_id). + """ + async with db.execute( + """ + SELECT it.* + FROM intradesk_tasks it + JOIN organizations o ON o.intradesk_id = it.client_id + WHERE o.id = ? + ORDER BY it.updated_at DESC + LIMIT ? OFFSET ? + """, + (local_org_id, page_size, page * page_size), + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +async def count_tasks_for_local_org( + db: aiosqlite.Connection, local_org_id: int +) -> int: + async with db.execute( + """ + SELECT COUNT(*) + FROM intradesk_tasks it + JOIN organizations o ON o.intradesk_id = it.client_id + WHERE o.id = ? + """, + (local_org_id,), + ) as cur: + row = await cur.fetchone() + return row[0] if row else 0 + + +async def get_task_for_local_org( + db: aiosqlite.Connection, task_id: int, local_org_id: int +) -> dict | None: + """Получить одну задачу, убедившись что она принадлежит организации пользователя.""" + async with db.execute( + """ + SELECT it.* + FROM intradesk_tasks it + JOIN organizations o ON o.intradesk_id = it.client_id + WHERE it.id = ? AND o.id = ? + """, + (task_id, local_org_id), + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + +# ── Contact person lookup ───────────────────────────────────────────────────── + +async def get_intradesk_contact_id_for_phone( + db: aiosqlite.Connection, client_intradesk_id: int, phone: str +) -> int | None: + """ + Найти id контактного лица в Intradesk по клиенту + нормализованному телефону. + phone — только цифры (как в bot_users.phone). + """ + async with db.execute( + "SELECT id, phone FROM intradesk_users WHERE client_id = ?", + (client_intradesk_id,), + ) as cur: + rows = await cur.fetchall() + for row in rows: + if _normalize_phone(row[1] or "") == phone: + return row[0] + return None + + # ── Whitelist sync ──────────────────────────────────────────────────────────── def _normalize_phone(raw: str) -> str: diff --git a/database/queries/tickets.py b/database/queries/tickets.py index 88badcf..e802e2a 100644 --- a/database/queries/tickets.py +++ b/database/queries/tickets.py @@ -81,6 +81,16 @@ async def get_all_tickets( return [dict(r) for r in rows] +async def set_intradesk_task_id(ticket_id: int, intradesk_task_id: int) -> None: + """Сохранить id созданной задачи Intradesk в локальной заявке.""" + db = get_db() + await db.execute( + "UPDATE tickets SET intradesk_task_id = ? WHERE id = ?", + (intradesk_task_id, ticket_id), + ) + await db.commit() + + async def update_ticket_status( ticket_id: int, status: str, last_comment: str | None ) -> None: diff --git a/database/schema.sql b/database/schema.sql index ba23c71..b2703f8 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -35,14 +35,15 @@ CREATE TABLE IF NOT EXISTS bot_users ( ); CREATE TABLE IF NOT EXISTS tickets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES organizations(id), - created_by INTEGER NOT NULL REFERENCES bot_users(id), - description TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'open', -- open / in_progress / closed - last_comment TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES organizations(id), + created_by INTEGER NOT NULL REFERENCES bot_users(id), + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', -- open / in_progress / closed + last_comment TEXT, + intradesk_task_id INTEGER, -- id задачи в Intradesk (NULL = не создано) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS ticket_attachments ( diff --git a/external/intradesk_api.py b/external/intradesk_api.py new file mode 100644 index 0000000..4962ca0 --- /dev/null +++ b/external/intradesk_api.py @@ -0,0 +1,149 @@ +""" +Intradesk REST API: создание заявок и чтение истории/комментариев. + +Эндпоинты: + POST /changes/v3/tasks — создать заявку + GET /taskhistory/api/v2.0/lifetime/{id}/full — история (комментарии, смена статуса...) +""" +from __future__ import annotations + +import json +import logging + +import httpx + +import config + +logger = logging.getLogger(__name__) + + +async def get_task_last_comment(task_id: int) -> dict | None: + """ + Получить последний комментарий к заявке из Intradesk. + + Возвращает dict: {text, author, date} или None если нет комментариев. + """ + key = config.INTRADESK_TASKS_API_KEY + if not key: + return None + + url = ( + f"{config.INTRADESK_BASE_URL.rstrip('/')}" + f"/taskhistory/api/v2.0/lifetime/{task_id}/full" + f"?ApiKey={key}&sortDirection=Desc&top=10&events=50" + ) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=15.0) + if resp.status_code != 200: + logger.warning( + "get_task_last_comment task=%s → HTTP %s", task_id, resp.status_code + ) + return None + data = resp.json() + except Exception as exc: + logger.warning("get_task_last_comment error: %s", exc) + return None + + # Ищем последнее событие с blockname="comment" (type=50) + for entry in data.get("data") or []: + for ev in (entry.get("events") or {}).get("data") or []: + if ev.get("blockname") == "comment" and ev.get("type") == 50: + raw_date = entry.get("eventat") or "" + # "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54" + short_date = raw_date[:16].replace("T", " ") + return { + "text": ev.get("stringvalue") or "", + "author": entry.get("username") or "—", + "date": short_date, + } + return None + + +async def create_intradesk_task( + name: str, + description: str, + client_intradesk_id: int | None = None, + contact_person_id: int | None = None, + attachment_urls: list[str] | None = None, +) -> dict | None: + """ + Создать заявку в Intradesk. + + Возвращает ответ API (dict с полями id, tasknumber и др.) или None при ошибке. + Вложения — пока добавляются как ссылки в конец описания (до появления upload API). + В dry-run режиме запрос печатается в консоль и возвращается None. + """ + key = config.INTRADESK_CHANGES_API_KEY + if not key: + 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), + } + # initiator: вариант 3 из документации — клиент + пользователь клиента. + # clientid заполняется автоматически из initiatorclient, отдельно не нужен. + 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}}, + ensure_ascii=False, + ) + elif client_intradesk_id is not None: + # только клиент (нет конкретного контактного лица) + blocks["initiator"] = json.dumps( + {"value": {"groupid": client_intradesk_id}}, ensure_ascii=False + ) + if config.INTRADESK_DEFAULT_SERVICE: + blocks["service"] = json.dumps( + {"value": config.INTRADESK_DEFAULT_SERVICE}, ensure_ascii=False + ) + if config.INTRADESK_DEFAULT_TASK_TYPE: + blocks["tasktype"] = json.dumps( + {"value": int(config.INTRADESK_DEFAULT_TASK_TYPE)}, ensure_ascii=False + ) + + body = {"blocks": blocks, "Channel": "web"} + url = f"{config.INTRADESK_BASE_URL.rstrip('/')}/changes/v3/tasks?ApiKey={key}" + if contact_person_id is not None: + url += f"&userid={contact_person_id}" + + if config.DRY_RUN: + print( + "\n[DRY-RUN] create_intradesk_task — запрос НЕ отправлен\n" + f" POST {url}\n" + f" Body: {json.dumps(body, ensure_ascii=False, indent=2)}\n" + ) + return None + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + url, + json=body, + headers={"Content-Type": "application/json"}, + timeout=20.0, + ) + if resp.status_code not in (200, 201): + logger.error( + "create_intradesk_task → HTTP %s: %s", + resp.status_code, + resp.text[:500], + ) + return None + result = resp.json() + logger.info( + "create_intradesk_task ok: id=%s number=%s", + result.get("id"), + result.get("tasknumber") or result.get("number"), + ) + return result + except Exception as exc: + logger.exception("create_intradesk_task error: %s", exc) + return None diff --git a/external/intradesk_sync.py b/external/intradesk_sync.py index fce82a4..03fb59a 100644 --- a/external/intradesk_sync.py +++ b/external/intradesk_sync.py @@ -96,6 +96,17 @@ async def _fetch_all( # ── Sync functions ──────────────────────────────────────────────────────────── +def _dry_run_print(label: str, items: list[dict], sample_keys: list[str]) -> None: + """Вывод первых 3 записей в консоль при dry-run.""" + print(f"\n[DRY-RUN] {label} — запись в БД НЕ выполнена, получено {len(items)} записей") + for i, item in enumerate(items[:3]): + preview = {k: item.get(k) for k in sample_keys if k in item} + print(f" [{i}] {preview}") + if len(items) > 3: + print(f" ... и ещё {len(items) - 3}") + print() + + async def sync_clients() -> int: """Синхронизировать клиентов/организации.""" items, _ = await _fetch_all( @@ -103,6 +114,9 @@ async def sync_clients() -> int: config.INTRADESK_API_KEY, {"$filter": "(isarchived eq false)", "$orderby": "name asc"}, ) + if config.DRY_RUN: + _dry_run_print("sync_clients", items, ["id", "name", "clientType", "email"]) + return len(items) db = get_db() for item in items: await upsert_client(db, item) @@ -118,6 +132,9 @@ async def sync_users() -> int: config.INTRADESK_API_KEY, {"$filter": "(isarchived eq false)", "$orderby": "id desc"}, ) + if config.DRY_RUN: + _dry_run_print("sync_users", items, ["id", "name", "email", "phoneNumbers"]) + return len(items) db = get_db() for item in items: await upsert_user(db, item) @@ -128,6 +145,9 @@ async def sync_users() -> int: async def sync_whitelist() -> int: """Обновить organizations + org_users из уже синхронизированных данных Intradesk.""" + if config.DRY_RUN: + print("[DRY-RUN] sync_whitelist — обновление org_users пропущено\n") + return 0 db = get_db() count = await sync_whitelist_from_intradesk(db) logger.info("sync_whitelist: %d org_users затронуто", count) @@ -158,6 +178,12 @@ async def sync_tasks() -> int: config.INTRADESK_TASKS_API_KEY, {"$filter": filter_str, "$orderby": "updatedat desc"}, ) + if config.DRY_RUN: + _dry_run_print( + "sync_tasks", items, + ["id", "tasknumber", "name", "status", "clientid", "updatedat"], + ) + return len(items) db = get_db() for item in items: await upsert_task(db, item, dicts_map) diff --git a/main.py b/main.py index efb069b..60e1cf2 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,15 @@ import config async def main() -> None: + if config.DRY_RUN: + print( + "\n" + "=" * 60 + "\n" + " ⚠️ DRY-RUN режим активен\n" + " Записи в Intradesk API выполняться НЕ будут.\n" + " Payload запросов будет выводиться в консоль.\n" + + "=" * 60 + "\n" + ) + await init_db() bot, dp = create_bot_and_dispatcher() diff --git a/run.bat b/run.bat index f8e0d9e..7fd0139 100644 --- a/run.bat +++ b/run.bat @@ -1,20 +1,3 @@ -@echo off -chcp 65001 >nul -title Support Bot - -:: Переходим в папку скрипта -cd /d "%~dp0" - -:: Активируем venv если есть -if exist "venv\Scripts\activate.bat" ( - call venv\Scripts\activate.bat -) else if exist ".venv\Scripts\activate.bat" ( - call .venv\Scripts\activate.bat -) - -:: Устанавливаем зависимости если нужно (раскомментировать при первом запуске) -:: pip install -r requirements.txt - echo [INFO] Запуск... Admin-панель: http://localhost:8080/admin/ echo.