перед кешем

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() state = await context.get_state()
creation_states = [ creation_states = [
TicketStates.waiting_description, TicketStates.waiting_description,
TicketStates.collecting_attachments, TicketStates.collecting_attachments,
TicketStates.confirming, TicketStates.confirming,
CommentStates.waiting_text,
] ]
if state not in creation_states: if state not in creation_states:
return return

View file

@ -1,47 +1,79 @@
"""Обработчик главного меню.""" """Обработчик главного меню."""
import asyncio
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 maxapi.context import BaseContext
from bot.keyboards import main_menu_kb from bot.keyboards import main_menu_kb
from bot.send_helper import bot_reply 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 database.queries.organizations import get_organization
from external.intradesk_api import count_client_open_tasks, get_client_last_comment
router = Router(router_id="menu") router = Router(router_id="menu")
async def build_main_menu_text(bot_user: dict) -> str: async def build_main_menu_text(bot_user: dict) -> str:
"""Формирует текст главного меню с именем объекта и статистикой заявок.""" """Формирует текст главного меню — данные берутся из Intradesk API."""
org_id = bot_user.get("org_id") org_id = bot_user.get("org_id")
org_name: str | None = None org_name: str | None = None
intradesk_id: int | None = None
if org_id: if org_id:
org = await get_organization(org_id) org = await get_organization(org_id)
org_name = org["name"] if org else None org_name = org["name"] if org else None
intradesk_id = org.get("intradesk_id") if org else None
lines: list[str] = [] lines: list[str] = []
if org_name: lines.append(f"🏢 {org_name}" if org_name else "🏠 Поддержка")
lines.append(f"🏢 {org_name}") lines.append("" * 16)
else:
lines.append("🏠 Главное меню")
if org_id: if intradesk_id:
db = get_db() # Счётчик активных и последний комментарий — параллельно
total = await count_tasks_for_local_org(db, org_id) active, last_comment = await asyncio.gather(
active = await count_active_tasks_for_local_org(db, org_id) count_client_open_tasks(intradesk_id),
get_client_last_comment(intradesk_id),
)
if total == 0: if active == 0:
lines.append("Заявок пока нет.") lines += [
"📭 Активных заявок нет",
"",
"Нажмите «Создать заявку» и мы",
"свяжемся с вами в ближайшее время.",
]
else: 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) 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"]: if bot_user["is_authorized"]:
logger.info("✅ bot_started returning user | user_id=%s", user.user_id) logger.info("✅ bot_started returning user | user_id=%s", user.user_id)
await context.set_state(None) 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( await bot_send(
event.bot, event.bot,
chat_id=event.chat_id, chat_id=event.chat_id,
user_id=user.user_id, user_id=user.user_id,
text="👋 С возвращением! Выберите действие:", text=menu_text,
attachments=[main_menu_kb()], attachments=[main_menu_kb()],
) )
return return
@ -141,11 +143,13 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
return return
await context.set_state(None) 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( 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()],
) )

View file

@ -19,11 +19,13 @@ 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 iticket_detail_kb, menu_only_kb from bot.keyboards import iticket_detail_kb, main_menu_kb, menu_only_kb
from bot.send_helper import bot_reply from bot.send_helper import bot_reply, bot_send
from bot.states import CommentStates
from database.connection import get_db 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 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") router = Router(router_id="view_tickets")
@ -42,19 +44,22 @@ def _status_icon(status_name: str) -> str:
def _fmt_date(iso: 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: if not iso or len(iso) < 10:
return iso or "" return iso or ""
y, m, d = iso[:10].split("-") 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: 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: if not iso or len(iso) < 10:
return iso or "" return iso or ""
y, m, d = iso[:10].split("-") 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: 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") comment = t.get("last_comment")
if comment and comment.get("text"): if comment and comment.get("text"):
ctext = comment["text"][:80] + ("" if len(comment["text"]) > 80 else "") 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 "") cdate = _fmt_date_short(comment.get("date") or "")
lines.append(f'💬 "{ctext}"{author_short} ({cdate})') lines.append(f'💬 "{ctext}"{author_short} ({cdate})')
else:
lines.append("💬 Нет комментариев")
return "\n".join(lines) return "\n".join(lines)
@ -247,7 +254,125 @@ async def cb_iticket_detail(
await bot_reply( await bot_reply(
event, event,
text="\n".join(lines), text="\n".join(lines),
attachments=[iticket_detail_kb()], attachments=[iticket_detail_kb(task_id=task["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,
) )
# ── Оставить комментарий ──────────────────────────────────────────────────────
@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() return builder.as_markup()
def iticket_detail_kb() -> Attachment: def iticket_detail_kb(task_id: int | None = None) -> Attachment:
"""Клавиатура внутри карточки Intradesk-задачи.""" """Клавиатура внутри карточки Intradesk-задачи."""
return ( builder = InlineKeyboardBuilder()
InlineKeyboardBuilder() if task_id is not None:
.row(CallbackButton(text="К списку заявок", payload="itickets_page:0")) builder.row(CallbackButton(
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu")) text="💬 Оставить комментарий",
.as_markup() 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: def ticket_detail_kb(page: int = 0) -> Attachment:

View file

@ -9,3 +9,7 @@ class TicketStates(StatesGroup):
waiting_description = State() # Ожидаем первое сообщение с описанием waiting_description = State() # Ожидаем первое сообщение с описанием
collecting_attachments = State() # Накапливаем доп. файлы/текст collecting_attachments = State() # Накапливаем доп. файлы/текст
confirming = 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 json
import logging import logging
import re
import httpx import httpx
@ -16,6 +17,18 @@ import config
logger = logging.getLogger(__name__) 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: 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" # "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54"
short_date = raw_date[:16].replace("T", " ") short_date = raw_date[:16].replace("T", " ")
return { return {
"text": ev.get("stringvalue") or "", "text": _strip_html(ev.get("stringvalue") or ""),
"author": entry.get("username") or "", "author": entry.get("username") or "",
"date": short_date, "date": short_date,
} }
return None 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( async def get_client_open_tasks(
client_id: int, client_id: int,
page: int = 0, page: int = 0,
@ -69,8 +157,10 @@ async def get_client_open_tasks(
Получить незакрытые заявки клиента из Intradesk API. Получить незакрытые заявки клиента из Intradesk API.
Возвращает {"tasks": [...], "total": int}. Возвращает {"tasks": [...], "total": int}.
Каждая задача содержит поля из OData + "last_comment" (dict или None). Последний комментарий загружается параллельно через history API для каждой задачи.
""" """
import asyncio
key = config.INTRADESK_TASKS_API_KEY key = config.INTRADESK_TASKS_API_KEY
if not key: if not key:
return {"tasks": [], "total": 0} return {"tasks": [], "total": 0}
@ -108,20 +198,31 @@ async def get_client_open_tasks(
if did: if did:
dicts_map[did] = dname dicts_map[did] = dname
tasks = [] # Базовые данные задач
base_tasks = []
for t in raw_tasks: for t in raw_tasks:
status_id = t.get("status") status_id = t.get("status")
last_comment = _extract_last_comment_from_lifetime(t.get("lifetime")) base_tasks.append({
tasks.append({
"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 "",
"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 "",
"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} 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 []: for ev in (entry.get("events") or {}).get("data") or []:
if ev.get("blockname") == "comment" and ev.get("type") == 50: if ev.get("blockname") == "comment" and ev.get("type") == 50:
raw_date = entry.get("eventat") or "" 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 { return {
"text": ev.get("stringvalue") or "", "text": _strip_html(ev.get("stringvalue") or ""),
"author": entry.get("username") or "", "author": entry.get("username") or "",
"date": short_date, "date": short_date,
} }
return None 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( async def create_intradesk_task(
name: str, name: str,
description: str, description: str,