правки 1
This commit is contained in:
parent
3e6ffe93d4
commit
96f70cb646
9 changed files with 257 additions and 19 deletions
|
|
@ -8,7 +8,9 @@
|
|||
"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\\(''_''\\)]\\)\")",
|
||||
"Bash(python -c \"from maxapi.types import SendedMessage; print\\(SendedMessage.__fields__ if hasattr\\(SendedMessage, ''__fields__''\\) else dir\\(SendedMessage\\)\\)\")",
|
||||
"Bash(python -c \"from maxapi.context import State; s = State\\(\\); print\\(type\\(s\\), dir\\(s\\)\\)\")"
|
||||
"Bash(python -c \"from maxapi.context import State; s = State\\(\\); print\\(type\\(s\\), dir\\(s\\)\\)\")",
|
||||
"Bash(python -c \"import maxapi; print\\(maxapi.__file__\\)\")",
|
||||
"Bash(find C:UsersProfessionalAppDataLocalProgramsPythonPython313Libsite-packagesmaxapi -name command.py -o -name *command*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
.env
1
.env
|
|
@ -23,3 +23,4 @@ INTRADESK_CHANGES_API_KEY=26221B839B6643E68345FD26BF
|
|||
INTRADESK_DEFAULT_SERVICE=84681/84683/
|
||||
# ID типа заявки (Настройки → Заявки → Типы заявок → ID)
|
||||
INTRADESK_DEFAULT_TASK_TYPE=21026
|
||||
INTRADESK_CLOSE_STATUS_ID=68045
|
||||
|
|
@ -18,8 +18,9 @@ from maxapi.dispatcher import Router
|
|||
from maxapi.filters import F
|
||||
from maxapi.types.updates.message_callback import MessageCallback
|
||||
|
||||
import config
|
||||
from bot.filters import PayloadStartsWith
|
||||
from bot.keyboards import iticket_detail_kb, main_menu_kb, menu_only_kb
|
||||
from bot.keyboards import comment_input_kb, iticket_detail_kb, main_menu_kb, menu_only_kb
|
||||
from bot.send_helper import bot_reply, bot_send
|
||||
from bot.states import CommentStates
|
||||
from database.connection import get_db
|
||||
|
|
@ -27,7 +28,9 @@ from database.queries.intradesk import get_intradesk_contact_id_for_phone
|
|||
from database.queries.organizations import get_organization
|
||||
from external.intradesk_api import (
|
||||
add_task_comment,
|
||||
close_task,
|
||||
get_client_open_tasks,
|
||||
get_task_comments,
|
||||
get_task_last_comment,
|
||||
invalidate_client_cache,
|
||||
invalidate_task_comment,
|
||||
|
|
@ -218,6 +221,23 @@ async def cb_iticket_detail(
|
|||
|
||||
await event.answer(notification="")
|
||||
|
||||
# Если пришли сюда из комментарийного флоу (кнопка «Назад») —
|
||||
# удаляем промпт-сообщение и сбрасываем state, сохраняя кэш задач.
|
||||
state = await context.get_state()
|
||||
if state == CommentStates.waiting_text:
|
||||
d = await context.get_data()
|
||||
for mid in (d.get("flow_message_ids") or []):
|
||||
try:
|
||||
await event.bot.delete_message(message_id=mid)
|
||||
except Exception:
|
||||
pass
|
||||
await context.set_state(None)
|
||||
await context.set_data({
|
||||
"tasks": d.get("tasks"),
|
||||
"page": d.get("page"),
|
||||
"total": d.get("total"),
|
||||
})
|
||||
|
||||
# Ищем задачу в кэше контекста (загружена при показе списка)
|
||||
data = await context.get_data()
|
||||
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
|
||||
|
|
@ -244,28 +264,104 @@ async def cb_iticket_detail(
|
|||
if task.get("name"):
|
||||
lines += ["", f"📄 {task['name']}"]
|
||||
|
||||
# Последний комментарий — отдельный запрос к history 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']}",
|
||||
]
|
||||
if task.get("description"):
|
||||
desc = task["description"]
|
||||
if len(desc) > 600:
|
||||
desc = desc[:600] + "…"
|
||||
lines += ["", "📝 Описание:", desc]
|
||||
|
||||
# Последние 10 комментариев из history API
|
||||
comments = await get_task_comments(task["id"], count=10)
|
||||
if comments:
|
||||
lines += ["", f"💬 Комментарии ({len(comments)}):"]
|
||||
for c in comments:
|
||||
ctext = c["text"]
|
||||
if len(ctext) > 300:
|
||||
ctext = ctext[:300] + "…"
|
||||
lines += [
|
||||
"",
|
||||
f"─" * 16,
|
||||
f"{c['date']} — {c['author']}",
|
||||
ctext,
|
||||
]
|
||||
else:
|
||||
lines += ["", "💬 Комментариев нет"]
|
||||
|
||||
await bot_reply(
|
||||
event,
|
||||
text="\n".join(lines),
|
||||
attachments=[iticket_detail_kb(task_id=task["id"])],
|
||||
attachments=[iticket_detail_kb(task_id=task["id"], can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
|
||||
user_id=bot_user["max_user_id"],
|
||||
chat_id=event.message.recipient.chat_id if event.message else None,
|
||||
)
|
||||
|
||||
|
||||
# ── Закрыть заявку ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.message_callback(PayloadStartsWith("iticket_close:"))
|
||||
async def cb_close_ticket(
|
||||
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||
) -> None:
|
||||
try:
|
||||
task_id = int(event.callback.payload.split(":")[1])
|
||||
except (IndexError, ValueError):
|
||||
await event.answer(notification="Ошибка")
|
||||
return
|
||||
|
||||
await event.answer(notification="")
|
||||
|
||||
# Ищем задачу в кэше
|
||||
data = await context.get_data()
|
||||
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
|
||||
task_number = task.get("task_number") if task else None
|
||||
|
||||
if not task_number:
|
||||
await bot_reply(
|
||||
event,
|
||||
text="⚠️ Не удалось определить номер заявки. Вернитесь к списку.",
|
||||
attachments=[iticket_detail_kb()],
|
||||
user_id=bot_user["max_user_id"],
|
||||
chat_id=event.message.recipient.chat_id if event.message else None,
|
||||
)
|
||||
return
|
||||
|
||||
# Получаем contact_person_id
|
||||
intradesk_id: int | None = None
|
||||
contact_person_id: int | None = None
|
||||
if bot_user.get("org_id"):
|
||||
org = await get_organization(bot_user["org_id"])
|
||||
intradesk_id = org.get("intradesk_id") if org else 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"]
|
||||
)
|
||||
|
||||
ok = await close_task(task_number=task_number, contact_person_id=contact_person_id)
|
||||
|
||||
if ok:
|
||||
# Сбрасываем кэш — задача теперь закрыта
|
||||
invalidate_task_comment(task_id)
|
||||
if intradesk_id:
|
||||
invalidate_client_cache(intradesk_id)
|
||||
num_str = f" #{task_number}" if task_number else ""
|
||||
await bot_reply(
|
||||
event,
|
||||
text=f"✅ Заявка{num_str} закрыта.",
|
||||
attachments=[menu_only_kb()],
|
||||
user_id=bot_user["max_user_id"],
|
||||
chat_id=event.message.recipient.chat_id if event.message else None,
|
||||
)
|
||||
else:
|
||||
await bot_reply(
|
||||
event,
|
||||
text="⚠️ Не удалось закрыть заявку. Попробуйте позже.",
|
||||
attachments=[iticket_detail_kb(task_id=task_id, can_close=bool(config.INTRADESK_CLOSE_STATUS_ID))],
|
||||
user_id=bot_user["max_user_id"],
|
||||
chat_id=event.message.recipient.chat_id if event.message else None,
|
||||
)
|
||||
|
||||
|
||||
# ── Оставить комментарий ──────────────────────────────────────────────────────
|
||||
|
||||
@router.message_callback(PayloadStartsWith("iticket_comment:"))
|
||||
|
|
@ -280,7 +376,7 @@ async def cb_add_comment(
|
|||
|
||||
await event.answer(notification="")
|
||||
|
||||
# Ищем task_number в кэше контекста
|
||||
# Ищем task_number в кэше контекста; сохраняем кэш задач для кнопки «Назад»
|
||||
data = await context.get_data()
|
||||
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
|
||||
task_number = task.get("task_number") if task else None
|
||||
|
|
@ -290,6 +386,10 @@ async def cb_add_comment(
|
|||
"comment_task_id": task_id,
|
||||
"comment_task_number": task_number,
|
||||
"flow_message_ids": [],
|
||||
# Сохраняем кэш списка чтобы кнопка «Назад» нашла задачу
|
||||
"tasks": data.get("tasks"),
|
||||
"page": data.get("page"),
|
||||
"total": data.get("total"),
|
||||
})
|
||||
|
||||
chat_id = event.message.recipient.chat_id if event.message else None
|
||||
|
|
@ -303,9 +403,9 @@ async def cb_add_comment(
|
|||
user_id=bot_user["max_user_id"],
|
||||
text=(
|
||||
f"💬 Напишите комментарий{num_str}:\n\n"
|
||||
"Для отмены нажмите «🏠 Главное меню»."
|
||||
"Отправьте текстовое сообщение или нажмите «← Назад к заявке» для отмены."
|
||||
),
|
||||
attachments=[menu_only_kb()],
|
||||
attachments=[comment_input_kb(task_id)],
|
||||
)
|
||||
# Сохраняем mid для возможной очистки
|
||||
if mid:
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ def intradesk_tasks_list_kb(
|
|||
return builder.as_markup()
|
||||
|
||||
|
||||
def iticket_detail_kb(task_id: int | None = None) -> Attachment:
|
||||
def iticket_detail_kb(task_id: int | None = None, can_close: bool = False) -> Attachment:
|
||||
"""Клавиатура внутри карточки Intradesk-задачи."""
|
||||
builder = InlineKeyboardBuilder()
|
||||
if task_id is not None:
|
||||
|
|
@ -126,11 +126,26 @@ def iticket_detail_kb(task_id: int | None = None) -> Attachment:
|
|||
text="💬 Оставить комментарий",
|
||||
payload=f"iticket_comment:{task_id}",
|
||||
))
|
||||
if task_id is not None and can_close:
|
||||
builder.row(CallbackButton(
|
||||
text="🔒 Закрыть заявку",
|
||||
payload=f"iticket_close:{task_id}",
|
||||
))
|
||||
builder.row(CallbackButton(text="← К списку заявок", payload="itickets_page:0"))
|
||||
builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def comment_input_kb(task_id: int) -> Attachment:
|
||||
"""Клавиатура при вводе комментария — кнопка назад и меню."""
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
.row(CallbackButton(text="← Назад к заявке", payload=f"iticket_detail:{task_id}"))
|
||||
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
|
||||
def ticket_detail_kb(page: int = 0) -> Attachment:
|
||||
return (
|
||||
InlineKeyboardBuilder()
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ INTRADESK_CHANGES_API_KEY: str = os.environ.get("INTRADESK_CHANGES_API_KEY", "")
|
|||
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_CLOSE_STATUS_ID: str = os.environ.get("INTRADESK_CLOSE_STATUS_ID", "")
|
||||
# ID группы исполнителей для фильтрации заявок
|
||||
INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "")
|
||||
# Список ID исполнителей через запятую (доп. фильтр)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
105
external/intradesk_api.py
vendored
105
external/intradesk_api.py
vendored
|
|
@ -87,6 +87,7 @@ def invalidate_client_cache(client_id: int) -> None:
|
|||
def invalidate_task_comment(task_id: int) -> None:
|
||||
"""Сбросить кэш истории конкретной задачи после добавления комментария."""
|
||||
_cache.pop(f"task_comment:{task_id}", None)
|
||||
_cache_delete_prefix(f"task_comments:{task_id}:")
|
||||
logger.debug("cache invalidated for task_id=%s", task_id)
|
||||
|
||||
|
||||
|
|
@ -144,6 +145,56 @@ async def get_task_last_comment(task_id: int) -> dict | None:
|
|||
return result
|
||||
|
||||
|
||||
async def get_task_comments(task_id: int, count: int = 10) -> list[dict]:
|
||||
"""
|
||||
Получить последние комментарии к заявке.
|
||||
|
||||
Возвращает список {text, author, date}, до count штук, новые сначала.
|
||||
Результат кэшируется на _TTL_TASK_COMMENT секунд.
|
||||
"""
|
||||
ck = f"task_comments:{task_id}:{count}"
|
||||
hit, cached = _cache_get(ck)
|
||||
if hit:
|
||||
return cached
|
||||
|
||||
api_key = config.INTRADESK_TASKS_API_KEY
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
url = (
|
||||
f"{config.INTRADESK_BASE_URL.rstrip('/')}"
|
||||
f"/taskhistory/api/v2.0/lifetime/{task_id}/full"
|
||||
f"?ApiKey={api_key}&sortDirection=Desc&top={count * 3}&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_comments task=%s → HTTP %s", task_id, resp.status_code)
|
||||
return []
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("get_task_comments error: %s", exc)
|
||||
return []
|
||||
|
||||
result: list[dict] = []
|
||||
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 ""
|
||||
result.append({
|
||||
"text": _strip_html(ev.get("stringvalue") or ""),
|
||||
"author": entry.get("username") or "—",
|
||||
"date": raw_date[:16].replace("T", " "),
|
||||
})
|
||||
break # одна запись = одно событие комментария
|
||||
if len(result) >= count:
|
||||
break
|
||||
|
||||
_cache_set(ck, result, _TTL_TASK_COMMENT)
|
||||
return result
|
||||
|
||||
|
||||
async def count_client_open_tasks(client_id: int) -> int:
|
||||
"""
|
||||
Количество незакрытых заявок клиента.
|
||||
|
|
@ -297,6 +348,7 @@ async def get_client_open_tasks(
|
|||
"id": t.get("id"),
|
||||
"task_number": t.get("tasknumber"),
|
||||
"name": t.get("name") or "",
|
||||
"description": _strip_html(t.get("description") or ""),
|
||||
"status_name": dicts_map.get(status_id, "") if status_id else "",
|
||||
"created_at": t.get("createdat") or "",
|
||||
"updated_at": t.get("updatedat") or "",
|
||||
|
|
@ -389,6 +441,59 @@ async def add_task_comment(
|
|||
return False
|
||||
|
||||
|
||||
async def close_task(
|
||||
task_number: int,
|
||||
contact_person_id: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Закрыть заявку — установить статус INTRADESK_CLOSE_STATUS_ID (PUT /changes/v3/tasks).
|
||||
|
||||
Возвращает True при успехе, False при ошибке или незаданном статусе.
|
||||
"""
|
||||
status_id = config.INTRADESK_CLOSE_STATUS_ID
|
||||
if not status_id:
|
||||
logger.warning("close_task: INTRADESK_CLOSE_STATUS_ID не задан")
|
||||
return False
|
||||
|
||||
key = config.INTRADESK_CHANGES_API_KEY
|
||||
if not key:
|
||||
logger.warning("close_task: INTRADESK_CHANGES_API_KEY не задан")
|
||||
return False
|
||||
|
||||
blocks: dict[str, str] = {
|
||||
"status": json.dumps({"value": int(status_id)}, ensure_ascii=False),
|
||||
}
|
||||
body = {"number": task_number, "blocks": blocks, "Channel": "api"}
|
||||
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] close_task — запрос НЕ отправлен\n"
|
||||
f" PUT {url}\n"
|
||||
f" Body: {json.dumps(body, ensure_ascii=False, indent=2)}\n"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.put(
|
||||
url,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=20.0,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
logger.error("close_task → HTTP %s: %s", resp.status_code, resp.text[:500])
|
||||
return False
|
||||
logger.info("close_task ok: task_number=%s", task_number)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.exception("close_task error: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
async def create_intradesk_task(
|
||||
name: str,
|
||||
description: str,
|
||||
|
|
|
|||
13
main.py
13
main.py
|
|
@ -7,6 +7,7 @@ setup_logging() # до всех остальных импортов
|
|||
from database.connection import init_db
|
||||
from bot.setup import create_bot_and_dispatcher
|
||||
from admin.app import create_admin_app
|
||||
from maxapi.types.command import BotCommand
|
||||
import config
|
||||
|
||||
|
||||
|
|
@ -23,6 +24,18 @@ async def main() -> None:
|
|||
await init_db()
|
||||
|
||||
bot, dp = create_bot_and_dispatcher()
|
||||
|
||||
# Регистрируем команды — появляются в меню кнопки "/" в поле ввода
|
||||
try:
|
||||
await bot.set_my_commands(
|
||||
BotCommand(name="start", description="Главное меню"),
|
||||
BotCommand(name="menu", description="Показать меню"),
|
||||
BotCommand(name="cancel", description="Отменить текущее действие"),
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("set_my_commands failed: %s", e)
|
||||
|
||||
admin_app = create_admin_app()
|
||||
|
||||
server = uvicorn.Server(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue