перед кешем

This commit is contained in:
dv 2026-03-26 16:16:39 +03:00
parent 7fab711886
commit 6f0e66b4ae
9 changed files with 369 additions and 46 deletions

View file

@ -44,12 +44,13 @@ async def cleanup_ticket_flow(bot, context: BaseContext) -> None:
Вызывается из любого обработчика, который прерывает этот поток
(главное меню, список заявок, повторное нажатие «Создать заявку»).
"""
from bot.states import TicketStates # avoid circular at module level
from bot.states import TicketStates, CommentStates # avoid circular at module level
state = await context.get_state()
creation_states = [
TicketStates.waiting_description,
TicketStates.collecting_attachments,
TicketStates.confirming,
CommentStates.waiting_text,
]
if state not in creation_states:
return

View file

@ -1,47 +1,79 @@
"""Обработчик главного меню."""
import asyncio
from maxapi.context import BaseContext
from maxapi.dispatcher import Router
from maxapi.filters import F
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_reply
from database.connection import get_db
from database.queries.intradesk import (
count_active_tasks_for_local_org,
count_tasks_for_local_org,
)
from database.queries.organizations import get_organization
from external.intradesk_api import count_client_open_tasks, get_client_last_comment
router = Router(router_id="menu")
async def build_main_menu_text(bot_user: dict) -> str:
"""Формирует текст главного меню с именем объекта и статистикой заявок."""
"""Формирует текст главного меню — данные берутся из Intradesk API."""
org_id = bot_user.get("org_id")
org_name: str | None = None
intradesk_id: int | None = None
if org_id:
org = await get_organization(org_id)
org_name = org["name"] if org else None
intradesk_id = org.get("intradesk_id") if org else None
lines: list[str] = []
if org_name:
lines.append(f"🏢 {org_name}")
else:
lines.append("🏠 Главное меню")
lines.append(f"🏢 {org_name}" if org_name else "🏠 Поддержка")
lines.append("" * 16)
if org_id:
db = get_db()
total = await count_tasks_for_local_org(db, org_id)
active = await count_active_tasks_for_local_org(db, org_id)
if intradesk_id:
# Счётчик активных и последний комментарий — параллельно
active, last_comment = await asyncio.gather(
count_client_open_tasks(intradesk_id),
get_client_last_comment(intradesk_id),
)
if total == 0:
lines.append("Заявок пока нет.")
if active == 0:
lines += [
"📭 Активных заявок нет",
"",
"Нажмите «Создать заявку» и мы",
"свяжемся с вами в ближайшее время.",
]
else:
lines.append(f"Заявок: {total} (активных: {active})")
lines.append(f"📋 Активных заявок: {active}")
lines.append("\nВыберите действие:")
if last_comment and last_comment.get("text"):
ctext = last_comment["text"]
if len(ctext) > 120:
ctext = ctext[:120] + ""
raw_date = last_comment.get("date") or ""
# raw_date приходит как "2026-03-26 11:54"
date_part = raw_date[:10]
time_part = raw_date[11:16] if len(raw_date) >= 16 else ""
if len(date_part) == 10:
y, m, d = date_part.split("-")
short_date = f"{d}.{m}" + (f" {time_part}" if time_part else "")
else:
short_date = raw_date
author = (last_comment.get("author") or "").strip()
task_num = last_comment.get("task_number")
num_str = f" (заявка #{task_num})" if task_num else ""
lines += [
"",
f"💬 Последний ответ{num_str}:",
f"«{ctext}»",
f"{author}, {short_date}",
]
else:
lines += ["", "💬 Новых ответов нет"]
else:
lines.append("📭 Нет данных об организации")
lines += ["", "" * 16, "Выберите действие 👇"]
return "\n".join(lines)

View file

@ -68,11 +68,13 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
if bot_user["is_authorized"]:
logger.info("✅ bot_started returning user | user_id=%s", user.user_id)
await context.set_state(None)
from bot.handlers.menu import build_main_menu_text
menu_text = "👋 С возвращением!\n\n" + await build_main_menu_text(bot_user)
await bot_send(
event.bot,
chat_id=event.chat_id,
user_id=user.user_id,
text="👋 С возвращением! Выберите действие:",
text=menu_text,
attachments=[main_menu_kb()],
)
return
@ -141,11 +143,13 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
return
await context.set_state(None)
from bot.handlers.menu import build_main_menu_text
menu_text = await build_main_menu_text(bot_user)
await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text="Главное меню:",
text=menu_text,
attachments=[main_menu_kb()],
)

View file

@ -19,11 +19,13 @@ from maxapi.filters import F
from maxapi.types.updates.message_callback import MessageCallback
from bot.filters import PayloadStartsWith
from bot.keyboards import iticket_detail_kb, menu_only_kb
from bot.send_helper import bot_reply
from bot.keyboards import 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
from database.queries.intradesk import get_intradesk_contact_id_for_phone
from database.queries.organizations import get_organization
from external.intradesk_api import get_client_open_tasks, get_task_last_comment
from external.intradesk_api import add_task_comment, get_client_open_tasks, get_task_last_comment
router = Router(router_id="view_tickets")
@ -42,19 +44,22 @@ def _status_icon(status_name: str) -> str:
def _fmt_date(iso: str) -> str:
"""'2026-03-26T09:57:11Z''26.03.2026'"""
"""'2026-03-26T09:57:11Z''26.03.2026 09:57'"""
if not iso or len(iso) < 10:
return iso or ""
y, m, d = iso[:10].split("-")
return f"{d}.{m}.{y}"
# iso может быть "2026-03-26T09:57:11Z" или "2026-03-26 09:57"
time_part = iso[11:16] if len(iso) >= 16 else ""
return f"{d}.{m}.{y}" + (f" {time_part}" if time_part else "")
def _fmt_date_short(iso: str) -> str:
"""'2026-03-26''26.03'"""
"""'2026-03-26 11:54''26.03 11:54'"""
if not iso or len(iso) < 10:
return iso or ""
y, m, d = iso[:10].split("-")
return f"{d}.{m}"
time_part = iso[11:16] if len(iso) >= 16 else ""
return f"{d}.{m}" + (f" {time_part}" if time_part else "")
def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
@ -79,9 +84,11 @@ def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
comment = t.get("last_comment")
if comment and comment.get("text"):
ctext = comment["text"][:80] + ("" if len(comment["text"]) > 80 else "")
author_short = (comment.get("author") or "").split()[0] # только фамилия
author_short = (comment.get("author") or "").split()[0]
cdate = _fmt_date_short(comment.get("date") or "")
lines.append(f'💬 "{ctext}"{author_short} ({cdate})')
else:
lines.append("💬 Нет комментариев")
return "\n".join(lines)
@ -247,7 +254,125 @@ async def cb_iticket_detail(
await bot_reply(
event,
text="\n".join(lines),
attachments=[iticket_detail_kb()],
attachments=[iticket_detail_kb(task_id=task["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:"))
async def cb_add_comment(
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="")
# Ищем 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
await context.set_state(CommentStates.waiting_text)
await context.set_data({
"comment_task_id": task_id,
"comment_task_number": task_number,
"flow_message_ids": [],
})
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
num_str = f" к заявке #{task_number}" if task_number else ""
mid = await bot_send(
event.bot,
chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=(
f"💬 Напишите комментарий{num_str}:\n\n"
"Для отмены нажмите «🏠 Главное меню»."
),
attachments=[menu_only_kb()],
)
# Сохраняем mid для возможной очистки
if mid:
d = await context.get_data()
d["flow_message_ids"] = [mid]
await context.set_data(d)
@router.message_created(states=CommentStates.waiting_text)
async def on_comment_text(
event: MessageCreated, context: BaseContext, bot_user: dict
) -> None:
chat_id, user_id = event.get_ids()
if chat_id is None:
return
body = event.message.body
text = (body.text or "").strip() if body else ""
if not text:
await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text="❗ Комментарий не может быть пустым. Напишите текст:",
attachments=[menu_only_kb()],
)
return
data = await context.get_data()
task_number = data.get("comment_task_number")
# Сбрасываем состояние до отправки API — защита от повторов
await context.clear()
# Получаем contact_person_id
contact_person_id: int | None = None
if bot_user.get("phone") and 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:
db = get_db()
contact_person_id = await get_intradesk_contact_id_for_phone(
db, intradesk_id, bot_user["phone"]
)
ok = await add_task_comment(
task_number=task_number,
text=text,
contact_person_id=contact_person_id,
)
from bot.handlers.menu import build_main_menu_text
if ok:
num_str = f" к заявке #{task_number}" if task_number else ""
await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text=f"✅ Комментарий{num_str} успешно отправлен!",
)
else:
await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text="⚠️ Не удалось отправить комментарий. Попробуйте позже.",
)
await bot_send(
event.bot,
chat_id=chat_id,
user_id=user_id,
text=await build_main_menu_text(bot_user),
attachments=[main_menu_kb()],
)

View file

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

View file

@ -9,3 +9,7 @@ class TicketStates(StatesGroup):
waiting_description = State() # Ожидаем первое сообщение с описанием
collecting_attachments = State() # Накапливаем доп. файлы/текст
confirming = State() # Предпросмотр — ждём подтверждения
class CommentStates(StatesGroup):
waiting_text = State() # Ожидаем текст комментария к заявке

Binary file not shown.

Binary file not shown.

View file

@ -9,6 +9,7 @@ from __future__ import annotations
import json
import logging
import re
import httpx
@ -16,6 +17,18 @@ import config
logger = logging.getLogger(__name__)
_TAG_RE = re.compile(r"<[^>]+>")
def _strip_html(text: str) -> str:
"""Убрать HTML-теги и лишние пробелы из текста комментария."""
if not text:
return text
cleaned = _TAG_RE.sub("", text)
# Схлопнуть множественные пробелы/переносы
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
return cleaned
async def get_task_last_comment(task_id: int) -> dict | None:
"""
@ -53,13 +66,88 @@ async def get_task_last_comment(task_id: int) -> dict | None:
# "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54"
short_date = raw_date[:16].replace("T", " ")
return {
"text": ev.get("stringvalue") or "",
"text": _strip_html(ev.get("stringvalue") or ""),
"author": entry.get("username") or "",
"date": short_date,
}
return None
async def count_client_open_tasks(client_id: int) -> int:
"""Количество незакрытых заявок клиента через API.
Intradesk не возвращает @odata.count при $top=0, поэтому берём $top=1
count в ответе отражает полный итог, а не размер страницы.
"""
key = config.INTRADESK_TASKS_API_KEY
if not key:
return 0
url = (
f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks"
f"?ApiKey={key}"
f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)"
f"&$top=1&$count=true"
)
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10.0)
if resp.status_code != 200:
return 0
data = resp.json()
# @odata.count — полный итог по фильтру, не зависит от $top
count = data.get("@odata.count")
if count is not None:
return int(count)
# Fallback: если count не вернулся — считаем по размеру value
return len(data.get("value") or [])
except Exception:
return 0
async def get_client_last_comment(client_id: int) -> dict | None:
"""
Найти последний комментарий по любой незакрытой заявке клиента.
Берём задачу с самым свежим updatedat ищем последний комментарий в её истории.
Возвращает {text, author, date, task_number} или None.
"""
key = config.INTRADESK_TASKS_API_KEY
if not key:
return None
# Шаг 1: самая свежая задача клиента
url = (
f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks"
f"?ApiKey={key}"
f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)"
f"&$orderby=updatedat desc&$top=1"
)
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=15.0)
if resp.status_code != 200:
return None
data = resp.json()
except Exception:
return None
tasks = data.get("value") or []
if not tasks:
return None
task = tasks[0]
task_id = task.get("id")
task_number = task.get("tasknumber")
if not task_id:
return None
# Шаг 2: последний комментарий этой задачи
comment = await get_task_last_comment(task_id)
if comment:
comment["task_number"] = task_number
return comment
async def get_client_open_tasks(
client_id: int,
page: int = 0,
@ -69,8 +157,10 @@ async def get_client_open_tasks(
Получить незакрытые заявки клиента из Intradesk API.
Возвращает {"tasks": [...], "total": int}.
Каждая задача содержит поля из OData + "last_comment" (dict или None).
Последний комментарий загружается параллельно через history API для каждой задачи.
"""
import asyncio
key = config.INTRADESK_TASKS_API_KEY
if not key:
return {"tasks": [], "total": 0}
@ -108,20 +198,31 @@ async def get_client_open_tasks(
if did:
dicts_map[did] = dname
tasks = []
# Базовые данные задач
base_tasks = []
for t in raw_tasks:
status_id = t.get("status")
last_comment = _extract_last_comment_from_lifetime(t.get("lifetime"))
tasks.append({
base_tasks.append({
"id": t.get("id"),
"task_number": t.get("tasknumber"),
"name": t.get("name") or "",
"status_name": dicts_map.get(status_id, "") if status_id else "",
"created_at": t.get("createdat") or "",
"updated_at": t.get("updatedat") or "",
"last_comment": last_comment,
})
# Параллельно загружаем последний комментарий для каждой задачи
task_ids = [t["id"] for t in base_tasks if t["id"]]
comments = await asyncio.gather(
*[get_task_last_comment(tid) for tid in task_ids],
return_exceptions=True,
)
tasks = []
for task, comment in zip(base_tasks, comments):
task["last_comment"] = comment if isinstance(comment, dict) else None
tasks.append(task)
return {"tasks": tasks, "total": total}
@ -133,15 +234,68 @@ def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None:
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 ""
short_date = raw_date[:10] # "2026-03-26"
short_date = raw_date[:16].replace("T", " ") # "2026-03-26 11:54"
return {
"text": ev.get("stringvalue") or "",
"text": _strip_html(ev.get("stringvalue") or ""),
"author": entry.get("username") or "",
"date": short_date,
}
return None
async def add_task_comment(
task_number: int,
text: str,
contact_person_id: int | None = None,
) -> bool:
"""
Добавить комментарий к заявке через Intradesk API (PUT /changes/v3/tasks).
Возвращает True при успехе, False при ошибке.
"""
key = config.INTRADESK_CHANGES_API_KEY
if not key:
logger.warning("add_task_comment: INTRADESK_CHANGES_API_KEY не задан")
return False
blocks: dict[str, str] = {
"comment": json.dumps({"value": text}, 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] add_task_comment — запрос НЕ отправлен\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(
"add_task_comment → HTTP %s: %s",
resp.status_code,
resp.text[:500],
)
return False
logger.info("add_task_comment ok: task_number=%s", task_number)
return True
except Exception as exc:
logger.exception("add_task_comment error: %s", exc)
return False
async def create_intradesk_task(
name: str,
description: str,