поправил создание заявки
This commit is contained in:
parent
1ee5049ab4
commit
9f32da9a0a
19 changed files with 645 additions and 145 deletions
|
|
@ -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
8
.env
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)],
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
18
config.py
18
config.py
|
|
@ -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.
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
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 ────────────────────────────────────────────────────────────
|
# ── 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)
|
||||||
|
|
|
||||||
9
main.py
9
main.py
|
|
@ -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
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 [INFO] Запуск... Admin-панель: http://localhost:8080/admin/
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue