поправил создание заявки
This commit is contained in:
parent
1ee5049ab4
commit
9f32da9a0a
19 changed files with 645 additions and 145 deletions
|
|
@ -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\\(''_''\\)]\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
.env
8
.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
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,65 +1,67 @@
|
|||
"""
|
||||
Обработчики просмотра заявок.
|
||||
Обработчики просмотра заявок (Intradesk).
|
||||
|
||||
Навигация полностью через callback payload — FSM-состояния не нужны.
|
||||
Навигация через callback payload — все переходы редактируют текущее сообщение:
|
||||
"view_tickets" → список, страница 0
|
||||
"tickets_page:{n}" → список, страница n
|
||||
"ticket_detail:{id}" → карточка заявки
|
||||
"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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
18
config.py
18
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 исполнителей через запятую (доп. фильтр)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS tickets (
|
|||
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'))
|
||||
);
|
||||
|
|
|
|||
149
external/intradesk_api.py
vendored
Normal file
149
external/intradesk_api.py
vendored
Normal 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
|
||||
26
external/intradesk_sync.py
vendored
26
external/intradesk_sync.py
vendored
|
|
@ -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)
|
||||
|
|
|
|||
9
main.py
9
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()
|
||||
|
|
|
|||
17
run.bat
17
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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue