поправил создание заявки

This commit is contained in:
dv 2026-03-26 14:48:52 +03:00
parent 1ee5049ab4
commit 9f32da9a0a
19 changed files with 645 additions and 145 deletions

View file

@ -1,7 +1,12 @@
{ {
"permissions": { "permissions": {
"allow": [ "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\\(''_''\\)]\\)\")"
] ]
} }
} }

8
.env
View file

@ -1,4 +1,4 @@
MAX_BOT_TOKEN=f9LHodD0cOJHqFlGFuM7t9dFSt0jA36NxLc5aSP0CwDuCyWzssGKsFwuP4ymzoP64uqe6Ivl4_bEWKAOl6MI MAX_BOT_TOKEN=f9LHodD0cOKyEOxn1KcWiRbcZSn9HqLPMd6RlI_M0-YMvNNwXyLANk65rHpu8dhatDoRO6YXJ-9-72P4R-cJ
# При локальном запуске (python main.py): # При локальном запуске (python main.py):
DB_PATH=data/support.db DB_PATH=data/support.db
@ -17,3 +17,9 @@ INTRADESK_API_KEY=4842AA91805F4630853EF42D74
INTRADESK_TASKS_API_KEY=26221B839B6643E68345FD26BF INTRADESK_TASKS_API_KEY=26221B839B6643E68345FD26BF
INTRADESK_EXECUTOR_GROUP=243025 INTRADESK_EXECUTOR_GROUP=243025
INTRADESK_EXECUTORS=2227907,525111,631481,2222953 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

View file

@ -23,12 +23,15 @@ from maxapi.types.updates.message_created import MessageCreated
from bot.keyboards import done_ticket_kb, main_menu_kb from bot.keyboards import done_ticket_kb, main_menu_kb
from bot.logging_setup import get_logger from bot.logging_setup import get_logger
from bot.send_helper import bot_send 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 from bot.states import TicketStates
logger = get_logger("bot.handlers.create_ticket") logger = get_logger("bot.handlers.create_ticket")
from database.queries.ticket_attachments import add_attachment from database.queries.ticket_attachments import add_attachment
from database.queries.tickets import create_ticket from database.queries.tickets import create_ticket, set_intradesk_task_id
import external.client as ext from external.intradesk_api import create_intradesk_task
router = Router(router_id="create_ticket") router = Router(router_id="create_ticket")
@ -164,13 +167,13 @@ async def cb_done_ticket(
created_by=bot_user["id"], created_by=bot_user["id"],
description=description, description=description,
) )
pending_atts = data.get("pending_attachments", [])
logger.info( logger.info(
"✅ ticket created | ticket_id=%s user_id=%s org_id=%s attachments=%s", "✅ ticket created | ticket_id=%s user_id=%s org_id=%s attachments=%s",
ticket_id, bot_user["max_user_id"], bot_user["org_id"], ticket_id, bot_user["max_user_id"], bot_user["org_id"], len(pending_atts),
len(data.get("pending_attachments", [])),
) )
for att in data.get("pending_attachments", []): for att in pending_atts:
await add_attachment( await add_attachment(
ticket_id=ticket_id, ticket_id=ticket_id,
attachment_type=att["type"], attachment_type=att["type"],
@ -178,22 +181,63 @@ async def cb_done_ticket(
) )
await context.clear() await context.clear()
await event.answer(notification="Заявка создана!") 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( 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=( text=confirm_text,
f"✅ Заявка №{ticket_id} успешно создана!\n"
"Наши специалисты свяжутся с вами в ближайшее время."
),
attachments=[main_menu_kb()],
) )
# Fire-and-forget: отправка во внешнюю систему menu_text = f"🏠 {org_name}\nВыберите действие:" if org_name else "🏠 Главное меню\nВыберите действие:"
async def _sync_external():
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, ext.sync_ticket, ticket_id)
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()],
)

View file

@ -5,11 +5,25 @@ from maxapi.types.updates.message_callback import MessageCallback
from maxapi.context import BaseContext from maxapi.context import BaseContext
from bot.keyboards import main_menu_kb 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") 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") @router.message_callback(F.callback.payload == "main_menu")
async def cb_main_menu( async def cb_main_menu(
event: MessageCallback, context: BaseContext, bot_user: dict event: MessageCallback, context: BaseContext, bot_user: dict
@ -17,14 +31,11 @@ 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 text = await _main_menu_text(bot_user)
if chat_id is None: await bot_reply(
return event,
text=text,
await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
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,
) )

View file

@ -30,6 +30,7 @@ from database.queries.bot_users import (
) )
from database.queries.org_users import find_by_phone from database.queries.org_users import find_by_phone
from database.queries.message_log import log_message from database.queries.message_log import log_message
from database.queries.organizations import get_organization
router = Router(router_id="start") router = Router(router_id="start")
@ -223,10 +224,18 @@ async def on_contact_shared(
) )
await context.set_state(None) 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( 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=menu_text,
attachments=[main_menu_kb()], attachments=[main_menu_kb()],
) )

View file

@ -1,65 +1,67 @@
""" """
Обработчики просмотра заявок. Обработчики просмотра заявок (Intradesk).
Навигация полностью через callback payload FSM-состояния не нужны. Навигация через callback payload все переходы редактируют текущее сообщение:
"view_tickets" список, страница 0 "view_tickets" список, страница 0
"tickets_page:{n}" список, страница n "itickets_page:{n}" список, страница n
"ticket_detail:{id}" карточка заявки "iticket_detail:{id}" карточка задачи
"main_menu" в menu.py "main_menu" в menu.py
""" """
import re
from maxapi.context import BaseContext from maxapi.context import BaseContext
from maxapi.dispatcher import Router from maxapi.dispatcher import Router
from maxapi.filters import F from maxapi.filters import F
from maxapi.types.updates.message_callback import MessageCallback from maxapi.types.updates.message_callback import MessageCallback
from bot.filters import PayloadStartsWith from bot.filters import PayloadStartsWith
from bot.keyboards import ticket_detail_kb, tickets_list_kb from bot.keyboards import intradesk_tasks_list_kb, iticket_detail_kb
from bot.send_helper import bot_send from bot.send_helper import bot_reply
from database.queries.ticket_attachments import get_attachments from database.connection import get_db
from database.queries.tickets import ( from database.queries.intradesk import (
PAGE_SIZE, count_tasks_for_local_org,
count_tickets_for_org, get_task_for_local_org,
get_ticket, get_tasks_for_local_org,
get_tickets_for_org,
) )
from external.intradesk_api import get_task_last_comment
router = Router(router_id="view_tickets") router = Router(router_id="view_tickets")
STATUS_LABELS = { _PAGE_SIZE = 8
"open": "🔴 Открыта",
"in_progress": "🟡 В работе",
"closed": "🟢 Закрыта",
}
async def _send_tickets_page( def _status_icon(status_name: str) -> str:
event: MessageCallback, page: int, bot_user: dict sn = status_name.lower()
) -> None: if "выполнен" in sn:
org_id = bot_user["org_id"] return "🟢"
tickets = await get_tickets_for_org(org_id, page=page) if "работ" in sn:
total = await count_tickets_for_org(org_id) 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: def _priority_icon(priority_name: str) -> str:
await bot_send( pn = priority_name.lower()
event.bot, if "критич" in pn:
chat_id=chat_id, return "🔴"
user_id=bot_user["max_user_id"], if "высок" in pn:
text="📭 У вашей организации пока нет заявок.", return "🟠"
attachments=[ticket_detail_kb(page=0)], if "средн" in pn:
) return "🟡"
return return ""
await bot_send(
event.bot, async def _tasks_page_text(org_id: int, page: int) -> tuple[str, list[dict], int]:
chat_id=chat_id, """Возвращает (текст, задачи, всего)."""
user_id=bot_user["max_user_id"], db = get_db()
text=f"📋 Заявки организации (стр. {page + 1}):", tasks = await get_tasks_for_local_org(db, org_id, page=page, page_size=_PAGE_SIZE)
attachments=[tickets_list_kb(tickets, page=page, total=total)], 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") @router.message_callback(F.callback.payload == "view_tickets")
@ -67,11 +69,19 @@ async def cb_view_tickets(
event: MessageCallback, context: BaseContext, bot_user: dict event: MessageCallback, context: BaseContext, bot_user: dict
) -> None: ) -> None:
await event.answer(notification="") await event.answer(notification="")
await _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:")) @router.message_callback(PayloadStartsWith("itickets_page:"))
async def cb_tickets_page( async def cb_itickets_page(
event: MessageCallback, context: BaseContext, bot_user: dict event: MessageCallback, context: BaseContext, bot_user: dict
) -> None: ) -> None:
await event.answer(notification="") await event.answer(notification="")
@ -79,63 +89,79 @@ async def cb_tickets_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 _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:")) @router.message_callback(PayloadStartsWith("iticket_detail:"))
async def cb_ticket_detail( async def cb_iticket_detail(
event: MessageCallback, context: BaseContext, bot_user: dict event: MessageCallback, context: BaseContext, bot_user: dict
) -> None: ) -> None:
try: try:
ticket_id = int(event.callback.payload.split(":")[1]) task_id = int(event.callback.payload.split(":")[1])
except (IndexError, ValueError): except (IndexError, ValueError):
await event.answer(notification="Ошибка") await event.answer(notification="Ошибка")
return 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 task is None:
if chat_id is None:
return
# Проверка доступа: заявка должна принадлежать той же организации
if ticket is None or ticket["org_id"] != bot_user["org_id"]:
await event.answer(notification="Нет доступа") await event.answer(notification="Нет доступа")
return return
await event.answer(notification="") await event.answer(notification="")
status_label = STATUS_LABELS.get(ticket["status"], ticket["status"]) sicon = _status_icon(task.get("status_name") or "")
text = ( picon = _priority_icon(task.get("priority_name") or "")
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']}"
# Вложения num = task.get("task_number") or task["id"]
attachments = await get_attachments(ticket_id) lines = [
if attachments: f"📌 Заявка #{num}",
att_lines = [] f"Статус: {sicon} {task.get('status_name') or ''}",
for a in attachments: f"Приоритет: {picon} {task.get('priority_name') or ''}",
icon = { ]
"AttachmentType.IMAGE": "🖼", if task.get("service_name"):
"AttachmentType.VIDEO": "🎬", lines.append(f"Сервис: {task['service_name']}")
"AttachmentType.AUDIO": "🎙", if task.get("executor_name"):
"AttachmentType.FILE": "📎", lines.append(f"Исполнитель: {task['executor_name']}")
}.get(a["attachment_type"], "📎") lines.append(f"Создана: {(task.get('created_at') or '')[:10] or ''}")
line = f"{icon} {a['attachment_type'].split('.')[-1].capitalize()}" lines.append(f"Обновлена: {(task.get('updated_at') or '')[:10] or ''}")
if a.get("attachment_url"):
line += f": {a['attachment_url']}"
att_lines.append(line)
text += "\n\n📎 Вложения:\n" + "\n".join(att_lines)
await bot_send( if task.get("name"):
event.bot, lines += ["", f"📄 {task['name']}"]
chat_id=chat_id,
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"], user_id=bot_user["max_user_id"],
text=text, chat_id=event.message.recipient.chat_id if event.message else None,
attachments=[ticket_detail_kb(page=0)],
) )

View file

@ -10,7 +10,7 @@ def main_menu_kb() -> Attachment:
return ( return (
InlineKeyboardBuilder() InlineKeyboardBuilder()
.row(CallbackButton(text="📝 Создать заявку", payload="create_ticket")) .row(CallbackButton(text="📝 Создать заявку", payload="create_ticket"))
.row(CallbackButton(text="📋 Просмотреть заявки", payload="view_tickets")) .row(CallbackButton(text="📋 Мои заявки", payload="view_tickets"))
.as_markup() .as_markup()
) )
@ -61,6 +61,53 @@ def tickets_list_kb(
return builder.as_markup() 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: def ticket_detail_kb(page: int = 0) -> Attachment:
return ( return (
InlineKeyboardBuilder() InlineKeyboardBuilder()

View file

@ -1,4 +1,4 @@
"""Обёртка над bot.send_message с автоматическим логированием исходящих.""" """Обёртки над bot.send_message / bot.edit_message с автоматическим логированием."""
from database.queries.message_log import log_message from database.queries.message_log import log_message
@ -10,6 +10,7 @@ async def bot_send(
text: str, text: str,
attachments=None, attachments=None,
) -> None: ) -> None:
"""Отправить новое сообщение."""
kw: dict = {"chat_id": chat_id, "text": text} kw: dict = {"chat_id": chat_id, "text": text}
if attachments is not None: if attachments is not None:
kw["attachments"] = attachments kw["attachments"] = attachments
@ -21,3 +22,69 @@ async def bot_send(
message_id=None, message_id=None,
text=text, 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,
)

View file

@ -1,4 +1,5 @@
import os import os
import sys
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
@ -6,6 +7,15 @@ load_dotenv()
BOT_TOKEN: str = os.environ["MAX_BOT_TOKEN"] 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 _project_root = Path(__file__).parent
_db_path_raw = os.environ.get("DB_PATH", "data/support.db") _db_path_raw = os.environ.get("DB_PATH", "data/support.db")
DB_PATH: str = str( 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") INTRADESK_BASE_URL: str = os.environ.get("INTRADESK_BASE_URL", "https://apigw.intradesk.ru")
# Ключ для /settings/odata (клиенты, пользователи) # Ключ для /settings/odata (клиенты, пользователи)
INTRADESK_API_KEY: str = os.environ.get("INTRADESK_API_KEY", "") 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", "") 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 группы исполнителей для фильтрации заявок # ID группы исполнителей для фильтрации заявок
INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "") INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "")
# Список ID исполнителей через запятую (доп. фильтр) # Список ID исполнителей через запятую (доп. фильтр)

Binary file not shown.

Binary file not shown.

View file

@ -36,6 +36,15 @@ async def init_db() -> None:
except Exception: except Exception:
pass # колонка уже существует 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: def get_db() -> aiosqlite.Connection:
if _db is None: if _db is None:

View file

@ -285,6 +285,88 @@ async def get_last_sync(
return dict(row) if row else None 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 ──────────────────────────────────────────────────────────── # ── Whitelist sync ────────────────────────────────────────────────────────────
def _normalize_phone(raw: str) -> str: def _normalize_phone(raw: str) -> str:

View file

@ -81,6 +81,16 @@ async def get_all_tickets(
return [dict(r) for r in rows] 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( async def update_ticket_status(
ticket_id: int, status: str, last_comment: str | None ticket_id: int, status: str, last_comment: str | None
) -> None: ) -> None:

View file

@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS tickets (
description TEXT NOT NULL, description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open', -- open / in_progress / closed status TEXT NOT NULL DEFAULT 'open', -- open / in_progress / closed
last_comment TEXT, last_comment TEXT,
intradesk_task_id INTEGER, -- id задачи в Intradesk (NULL = не создано)
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );

149
external/intradesk_api.py vendored Normal file
View file

@ -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

View file

@ -96,6 +96,17 @@ async def _fetch_all(
# ── Sync functions ──────────────────────────────────────────────────────────── # ── 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: async def sync_clients() -> int:
"""Синхронизировать клиентов/организации.""" """Синхронизировать клиентов/организации."""
items, _ = await _fetch_all( items, _ = await _fetch_all(
@ -103,6 +114,9 @@ async def sync_clients() -> int:
config.INTRADESK_API_KEY, config.INTRADESK_API_KEY,
{"$filter": "(isarchived eq false)", "$orderby": "name asc"}, {"$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() db = get_db()
for item in items: for item in items:
await upsert_client(db, item) await upsert_client(db, item)
@ -118,6 +132,9 @@ async def sync_users() -> int:
config.INTRADESK_API_KEY, config.INTRADESK_API_KEY,
{"$filter": "(isarchived eq false)", "$orderby": "id desc"}, {"$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() db = get_db()
for item in items: for item in items:
await upsert_user(db, item) await upsert_user(db, item)
@ -128,6 +145,9 @@ async def sync_users() -> int:
async def sync_whitelist() -> int: async def sync_whitelist() -> int:
"""Обновить organizations + org_users из уже синхронизированных данных Intradesk.""" """Обновить organizations + org_users из уже синхронизированных данных Intradesk."""
if config.DRY_RUN:
print("[DRY-RUN] sync_whitelist — обновление org_users пропущено\n")
return 0
db = get_db() db = get_db()
count = await sync_whitelist_from_intradesk(db) count = await sync_whitelist_from_intradesk(db)
logger.info("sync_whitelist: %d org_users затронуто", count) logger.info("sync_whitelist: %d org_users затронуто", count)
@ -158,6 +178,12 @@ async def sync_tasks() -> int:
config.INTRADESK_TASKS_API_KEY, config.INTRADESK_TASKS_API_KEY,
{"$filter": filter_str, "$orderby": "updatedat desc"}, {"$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() db = get_db()
for item in items: for item in items:
await upsert_task(db, item, dicts_map) await upsert_task(db, item, dicts_map)

View file

@ -11,6 +11,15 @@ import config
async def main() -> None: 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() await init_db()
bot, dp = create_bot_and_dispatcher() bot, dp = create_bot_and_dispatcher()

17
run.bat
View file

@ -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 [INFO] Запуск... Admin-панель: http://localhost:8080/admin/
echo. echo.