правки 1

This commit is contained in:
dv 2026-03-27 16:25:40 +03:00
parent 3e6ffe93d4
commit 96f70cb646
9 changed files with 257 additions and 19 deletions

View file

@ -8,7 +8,9 @@
"Bash(python -c \"from maxapi import Bot; import inspect; print\\(inspect.signature\\(Bot.edit_message\\)\\)\")", "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.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.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
View file

@ -23,3 +23,4 @@ INTRADESK_CHANGES_API_KEY=26221B839B6643E68345FD26BF
INTRADESK_DEFAULT_SERVICE=84681/84683/ INTRADESK_DEFAULT_SERVICE=84681/84683/
# ID типа заявки (Настройки → Заявки → Типы заявок → ID) # ID типа заявки (Настройки → Заявки → Типы заявок → ID)
INTRADESK_DEFAULT_TASK_TYPE=21026 INTRADESK_DEFAULT_TASK_TYPE=21026
INTRADESK_CLOSE_STATUS_ID=68045

View file

@ -18,8 +18,9 @@ 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
import config
from bot.filters import PayloadStartsWith 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.send_helper import bot_reply, bot_send
from bot.states import CommentStates from bot.states import CommentStates
from database.connection import get_db 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 database.queries.organizations import get_organization
from external.intradesk_api import ( from external.intradesk_api import (
add_task_comment, add_task_comment,
close_task,
get_client_open_tasks, get_client_open_tasks,
get_task_comments,
get_task_last_comment, get_task_last_comment,
invalidate_client_cache, invalidate_client_cache,
invalidate_task_comment, invalidate_task_comment,
@ -218,6 +221,23 @@ async def cb_iticket_detail(
await event.answer(notification="") 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() data = await context.get_data()
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None) task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
@ -244,23 +264,99 @@ async def cb_iticket_detail(
if task.get("name"): if task.get("name"):
lines += ["", f"📄 {task['name']}"] lines += ["", f"📄 {task['name']}"]
# Последний комментарий — отдельный запрос к history API if task.get("description"):
comment = await get_task_last_comment(task["id"]) desc = task["description"]
if comment and comment.get("text"): if len(desc) > 600:
ctext = comment["text"] desc = desc[:600] + ""
if len(ctext) > 500: lines += ["", "📝 Описание:", desc]
ctext = ctext[:500] + ""
# Последние 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 += [ lines += [
"", "",
f"💭 Последний комментарий ({comment['date']}):", f"" * 16,
f" {ctext}", f"{c['date']}{c['author']}",
f"{comment['author']}", ctext,
] ]
else:
lines += ["", "💬 Комментариев нет"]
await bot_reply( await bot_reply(
event, event,
text="\n".join(lines), 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"], user_id=bot_user["max_user_id"],
chat_id=event.message.recipient.chat_id if event.message else None, chat_id=event.message.recipient.chat_id if event.message else None,
) )
@ -280,7 +376,7 @@ async def cb_add_comment(
await event.answer(notification="") await event.answer(notification="")
# Ищем task_number в кэше контекста # Ищем task_number в кэше контекста; сохраняем кэш задач для кнопки «Назад»
data = await context.get_data() data = await context.get_data()
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None) 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 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_id": task_id,
"comment_task_number": task_number, "comment_task_number": task_number,
"flow_message_ids": [], "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 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"], user_id=bot_user["max_user_id"],
text=( text=(
f"💬 Напишите комментарий{num_str}:\n\n" f"💬 Напишите комментарий{num_str}:\n\n"
"Для отмены нажмите «🏠 Главное меню»." "Отправьте текстовое сообщение или нажмите «← Назад к заявке» для отмены."
), ),
attachments=[menu_only_kb()], attachments=[comment_input_kb(task_id)],
) )
# Сохраняем mid для возможной очистки # Сохраняем mid для возможной очистки
if mid: if mid:

View file

@ -118,7 +118,7 @@ def intradesk_tasks_list_kb(
return builder.as_markup() 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-задачи.""" """Клавиатура внутри карточки Intradesk-задачи."""
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if task_id is not None: if task_id is not None:
@ -126,11 +126,26 @@ def iticket_detail_kb(task_id: int | None = None) -> Attachment:
text="💬 Оставить комментарий", text="💬 Оставить комментарий",
payload=f"iticket_comment:{task_id}", 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="itickets_page:0"))
builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
return builder.as_markup() 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: def ticket_detail_kb(page: int = 0) -> Attachment:
return ( return (
InlineKeyboardBuilder() InlineKeyboardBuilder()

View file

@ -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", "") INTRADESK_DEFAULT_SERVICE: str = os.environ.get("INTRADESK_DEFAULT_SERVICE", "")
# ID типа заявки по умолчанию (Настройки → Заявки → Типы заявок → ID) # ID типа заявки по умолчанию (Настройки → Заявки → Типы заявок → ID)
INTRADESK_DEFAULT_TASK_TYPE: str = os.environ.get("INTRADESK_DEFAULT_TASK_TYPE", "") 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 группы исполнителей для фильтрации заявок # ID группы исполнителей для фильтрации заявок
INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "") INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "")
# Список ID исполнителей через запятую (доп. фильтр) # Список ID исполнителей через запятую (доп. фильтр)

Binary file not shown.

Binary file not shown.

View file

@ -87,6 +87,7 @@ def invalidate_client_cache(client_id: int) -> None:
def invalidate_task_comment(task_id: int) -> None: def invalidate_task_comment(task_id: int) -> None:
"""Сбросить кэш истории конкретной задачи после добавления комментария.""" """Сбросить кэш истории конкретной задачи после добавления комментария."""
_cache.pop(f"task_comment:{task_id}", 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) 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 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: async def count_client_open_tasks(client_id: int) -> int:
""" """
Количество незакрытых заявок клиента. Количество незакрытых заявок клиента.
@ -297,6 +348,7 @@ async def get_client_open_tasks(
"id": t.get("id"), "id": t.get("id"),
"task_number": t.get("tasknumber"), "task_number": t.get("tasknumber"),
"name": t.get("name") or "", "name": t.get("name") or "",
"description": _strip_html(t.get("description") or ""),
"status_name": dicts_map.get(status_id, "") if status_id else "", "status_name": dicts_map.get(status_id, "") if status_id else "",
"created_at": t.get("createdat") or "", "created_at": t.get("createdat") or "",
"updated_at": t.get("updatedat") or "", "updated_at": t.get("updatedat") or "",
@ -389,6 +441,59 @@ async def add_task_comment(
return False 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( async def create_intradesk_task(
name: str, name: str,
description: str, description: str,

13
main.py
View file

@ -7,6 +7,7 @@ setup_logging() # до всех остальных импортов
from database.connection import init_db from database.connection import init_db
from bot.setup import create_bot_and_dispatcher from bot.setup import create_bot_and_dispatcher
from admin.app import create_admin_app from admin.app import create_admin_app
from maxapi.types.command import BotCommand
import config import config
@ -23,6 +24,18 @@ async def main() -> None:
await init_db() await init_db()
bot, dp = create_bot_and_dispatcher() 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() admin_app = create_admin_app()
server = uvicorn.Server( server = uvicorn.Server(