532 lines
20 KiB
Python
532 lines
20 KiB
Python
import html
|
|
import logging
|
|
|
|
import httpx
|
|
from aiogram import F, Router
|
|
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
|
|
from aiogram.filters import Command, CommandObject
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.fsm.state import State, StatesGroup
|
|
from aiogram.types import (
|
|
CallbackQuery,
|
|
ForceReply,
|
|
InlineKeyboardMarkup,
|
|
Message,
|
|
)
|
|
|
|
from .binding import BindingService
|
|
from .deals import (
|
|
DealAssignmentConflict,
|
|
DealCommentSaveError,
|
|
DealService,
|
|
)
|
|
from .domain import Binding, DealPage
|
|
from .middleware import BindingRequiredMiddleware
|
|
from .presentation import DealFormatter, DealKeyboards
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DealAdvanceStates(StatesGroup):
|
|
waiting_comment = State()
|
|
|
|
|
|
def normalize_comment(value: str | None) -> str | None:
|
|
comment = (value or "").strip()
|
|
if not comment:
|
|
return None
|
|
if all(
|
|
character.isspace() or character in "-‐‑‒–—―"
|
|
for character in comment
|
|
):
|
|
return None
|
|
return comment
|
|
|
|
|
|
class StartBotHandlers:
|
|
"""Справка и погашение одноразовой ссылки."""
|
|
|
|
def __init__(self, bindings: BindingService) -> None:
|
|
self.bindings = bindings
|
|
self.router = Router(name="start")
|
|
self.router.message.register(self.start, Command("start", "help"))
|
|
|
|
async def start(self, message: Message, command: CommandObject) -> None:
|
|
payload = (command.args or "").strip()
|
|
# Если команда /start пришла с параметром bind_*,
|
|
# то это одноразовая ссылка для привязки.
|
|
if command.command == "start" and payload.startswith("bind_"):
|
|
await self.bind(message, payload.removeprefix("bind_"))
|
|
return
|
|
|
|
# pyrefly: ignore [missing-attribute]
|
|
binding = await self.bindings.find(message.from_user.id)
|
|
if binding:
|
|
await message.answer(
|
|
"Аккаунт привязан. /deals покажет сделки, а /deal ID откроет карточку."
|
|
)
|
|
else:
|
|
await message.answer(
|
|
"Открой приложение в Bitrix24 и нажми кнопку привязки Telegram."
|
|
)
|
|
|
|
async def bind(self, message: Message, token: str) -> None:
|
|
if message.chat.type != "private":
|
|
await message.answer(
|
|
"Привязку нужно открыть в личном чате с ботом.")
|
|
return
|
|
|
|
binding = await self.bindings.consume(
|
|
token,
|
|
message.from_user.id, # pyrefly: ignore [missing-attribute]
|
|
message.chat.id
|
|
)
|
|
if not binding:
|
|
await message.answer("Ссылка недействительна или уже использована.")
|
|
return
|
|
|
|
await message.answer(
|
|
"Telegram успешно привязан к Bitrix24. Теперь доступна команда /deals."
|
|
)
|
|
|
|
|
|
class DealBotHandlers:
|
|
"""Команды и inline-кнопки для сделок."""
|
|
|
|
def __init__(
|
|
self,
|
|
service: DealService,
|
|
bindings: BindingService | None = None
|
|
) -> None:
|
|
self.service = service
|
|
self.router = Router(name="deals")
|
|
self.router.message.register(self.deals, Command("leads", "deals"))
|
|
self.router.message.register(self.deal_by_command,
|
|
Command("lead", "deal"))
|
|
self.router.message.register(
|
|
self.cancel_stage_advance,
|
|
DealAdvanceStates.waiting_comment,
|
|
Command("cancel"),
|
|
)
|
|
self.router.message.register(
|
|
self.advance_stage,
|
|
DealAdvanceStates.waiting_comment,
|
|
F.text,
|
|
)
|
|
self.router.message.register(
|
|
self.require_stage_comment,
|
|
DealAdvanceStates.waiting_comment,
|
|
)
|
|
self.router.callback_query.register(
|
|
self.deals_page, F.data.startswith("deals:page:")
|
|
)
|
|
self.router.callback_query.register(
|
|
self.deal_by_button, F.data.startswith("deal:view:")
|
|
)
|
|
self.router.callback_query.register(
|
|
self.assign_responsible, F.data.startswith("deal:assign:")
|
|
)
|
|
self.router.callback_query.register(
|
|
self.request_stage_advance,
|
|
F.data.startswith("deal:advance:"),
|
|
)
|
|
self.router.callback_query.register(
|
|
self.show_history, F.data.startswith("deal:history:")
|
|
)
|
|
self.router.callback_query.register(
|
|
self.remind_to_call, F.data.startswith("deal:remind:")
|
|
)
|
|
|
|
# Подвязываем middleware, который проверяет наличие привязки к Битриксу.
|
|
if bindings:
|
|
middleware = BindingRequiredMiddleware(bindings)
|
|
self.router.message.middleware(middleware)
|
|
self.router.callback_query.middleware(middleware)
|
|
|
|
async def deals(self, message: Message, binding: Binding) -> None:
|
|
try:
|
|
await self.send_deals_page(message, binding, stage_key="new",
|
|
page=0)
|
|
except Exception as error:
|
|
await self.answer_error(message, error)
|
|
|
|
async def deal_by_command(
|
|
self,
|
|
message: Message,
|
|
command: CommandObject,
|
|
binding: Binding
|
|
) -> None:
|
|
deal_id = (command.args or "").strip()
|
|
if not deal_id.isdigit():
|
|
await message.answer(
|
|
"Укажи ID сделки: <code>/deal 123</code>",
|
|
parse_mode="HTML",
|
|
)
|
|
return
|
|
|
|
try:
|
|
await self.send_deal(message, binding, deal_id)
|
|
except Exception as error:
|
|
await self.answer_error(message, error)
|
|
|
|
async def deals_page(self, callback: CallbackQuery,
|
|
binding: Binding) -> None:
|
|
parts = (callback.data or "deals:page:new:0").split(":")
|
|
stage_key = parts[2] if len(parts) > 2 else "new"
|
|
page = int(parts[3]) if len(parts) > 3 else 0
|
|
|
|
try:
|
|
deal_page = await self.service.list_by_stage(binding, stage_key,
|
|
page)
|
|
changed = await self.edit_callback_message(
|
|
callback,
|
|
self.page_text(deal_page),
|
|
DealKeyboards.deals_page(
|
|
deal_page.deals,
|
|
deal_page.stage_filters,
|
|
deal_page.stage_filter.key,
|
|
deal_page.page,
|
|
deal_page.has_next
|
|
),
|
|
unchanged_text="Список уже актуален."
|
|
)
|
|
if changed:
|
|
await callback.answer()
|
|
|
|
except Exception as error:
|
|
await self.answer_callback_error(callback, error)
|
|
|
|
async def deal_by_button(self, callback: CallbackQuery,
|
|
binding: Binding) -> None:
|
|
deal_id = (callback.data or "").split(":")[-1]
|
|
try:
|
|
deal = await self.service.get(binding, deal_id)
|
|
if not deal:
|
|
await callback.answer("Сделка не найдена.", show_alert=True)
|
|
return
|
|
|
|
changed = await self.edit_callback_message(
|
|
callback,
|
|
DealFormatter.deal_details(deal),
|
|
DealKeyboards.deal_card(
|
|
deal,
|
|
binding.bitrix_user_id,
|
|
),
|
|
unchanged_text="Карточка уже открыта."
|
|
)
|
|
if changed:
|
|
await callback.answer()
|
|
|
|
except Exception as error:
|
|
await self.answer_callback_error(callback, error)
|
|
|
|
async def assign_responsible(
|
|
self, callback: CallbackQuery, binding: Binding
|
|
) -> None:
|
|
parts = (callback.data or "").split(":")
|
|
if len(parts) != 4:
|
|
await callback.answer(
|
|
"Карточка устарела. Откройте сделку заново.",
|
|
show_alert=True
|
|
)
|
|
return
|
|
|
|
deal_id, expected_responsible_id = parts[2], parts[3]
|
|
try:
|
|
assigned = await self.service.take_to_work(
|
|
binding,
|
|
deal_id,
|
|
expected_responsible_id
|
|
)
|
|
deal = await self.service.get(binding, deal_id)
|
|
if not deal:
|
|
await callback.answer(
|
|
"Сделка обновлена, но повторно не найдена.",
|
|
show_alert=True
|
|
)
|
|
return
|
|
|
|
changed = await self.edit_callback_message(
|
|
callback,
|
|
DealFormatter.deal_details(deal),
|
|
DealKeyboards.deal_card(
|
|
deal,
|
|
binding.bitrix_user_id,
|
|
),
|
|
unchanged_text="Сделка уже отображается актуально."
|
|
)
|
|
if changed:
|
|
text = (
|
|
"Сделка переведена в работу."
|
|
if assigned
|
|
else "Вы уже ответственный за эту сделку."
|
|
)
|
|
await callback.answer(text)
|
|
|
|
except DealAssignmentConflict as error:
|
|
await callback.answer(str(error), show_alert=True)
|
|
except Exception as error:
|
|
await self.answer_callback_error(callback, error)
|
|
|
|
async def request_stage_advance(
|
|
self,
|
|
callback: CallbackQuery,
|
|
binding: Binding,
|
|
state: FSMContext,
|
|
) -> None:
|
|
deal_id = (callback.data or "").split(":")[-1]
|
|
if not deal_id.isdigit() or not isinstance(callback.message, Message):
|
|
await callback.answer(
|
|
"Карточка устарела. Откройте сделку заново.",
|
|
show_alert=True,
|
|
)
|
|
return
|
|
|
|
try:
|
|
advance = await self.service.prepare_stage_advance(
|
|
binding,
|
|
deal_id,
|
|
)
|
|
await state.set_state(DealAdvanceStates.waiting_comment)
|
|
await state.set_data(
|
|
{
|
|
"deal_id": advance.deal_id,
|
|
"current_stage_id": advance.current_stage_id,
|
|
"target_stage_id": advance.target_stage_id,
|
|
"target_stage_title": advance.target_stage_title,
|
|
"is_final": advance.is_final,
|
|
}
|
|
)
|
|
|
|
final_note = " (финальная)" if advance.is_final else ""
|
|
await callback.message.answer(
|
|
(
|
|
f"Следующая стадия: "
|
|
f"<b>{html.escape(advance.target_stage_title)}</b>"
|
|
f"{final_note}.\n"
|
|
"Введите комментарий одним сообщением. "
|
|
"Чтобы продолжить без комментария, отправьте "
|
|
"<code>-</code>. Для отмены — <code>/cancel</code>."
|
|
),
|
|
parse_mode="HTML",
|
|
reply_markup=ForceReply(
|
|
selective=True,
|
|
input_field_placeholder="Комментарий или -",
|
|
),
|
|
)
|
|
await callback.answer("Жду комментарий.")
|
|
except Exception as error:
|
|
await self.answer_callback_error(callback, error)
|
|
|
|
async def advance_stage(
|
|
self,
|
|
message: Message,
|
|
binding: Binding,
|
|
state: FSMContext,
|
|
) -> None:
|
|
data = await state.get_data()
|
|
await state.clear()
|
|
deal_id = str(data.get("deal_id") or "")
|
|
expected_stage_id = str(data.get("current_stage_id") or "")
|
|
target_stage_id = str(data.get("target_stage_id") or "")
|
|
if not deal_id or not expected_stage_id or not target_stage_id:
|
|
await message.answer(
|
|
"Запрос устарел. Откройте карточку сделки заново."
|
|
)
|
|
return
|
|
|
|
comment = normalize_comment(message.text)
|
|
try:
|
|
advance = await self.service.advance_stage(
|
|
binding,
|
|
deal_id,
|
|
expected_stage_id,
|
|
target_stage_id,
|
|
comment,
|
|
)
|
|
if advance.is_final:
|
|
result_text = (
|
|
"Сделка переведена на финальную стадию "
|
|
f"«{html.escape(advance.target_stage_title)}»."
|
|
)
|
|
else:
|
|
result_text = (
|
|
"Сделка переведена на стадию "
|
|
f"«{html.escape(advance.target_stage_title)}»."
|
|
)
|
|
if comment:
|
|
result_text += " Комментарий добавлен в таймлайн."
|
|
else:
|
|
result_text += " Переход выполнен без комментария."
|
|
await message.answer(result_text, parse_mode="HTML")
|
|
await self.send_deal(message, binding, deal_id)
|
|
except DealCommentSaveError:
|
|
await message.answer(
|
|
(
|
|
"Стадия изменена, но комментарий не удалось "
|
|
"сохранить в Битриксе."
|
|
)
|
|
)
|
|
try:
|
|
await self.send_deal(message, binding, deal_id)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to refresh deal after comment save error"
|
|
)
|
|
except Exception as error:
|
|
await self.answer_error(message, error)
|
|
|
|
@staticmethod
|
|
async def cancel_stage_advance(
|
|
message: Message,
|
|
state: FSMContext,
|
|
) -> None:
|
|
await state.clear()
|
|
await message.answer("Переход на следующую стадию отменен.")
|
|
|
|
@staticmethod
|
|
async def require_stage_comment(message: Message) -> None:
|
|
await message.answer(
|
|
"Отправьте комментарий текстом или прочерк "
|
|
"<code>-</code>, чтобы продолжить без него.",
|
|
parse_mode="HTML",
|
|
)
|
|
|
|
async def show_history(self, callback: CallbackQuery,
|
|
binding: Binding) -> None:
|
|
deal_id = (callback.data or "").split(":")[-1]
|
|
try:
|
|
events = await self.service.history(binding, deal_id)
|
|
changed = await self.edit_callback_message(
|
|
callback,
|
|
DealFormatter.deal_history(deal_id, events),
|
|
DealKeyboards.deal_history(deal_id),
|
|
unchanged_text="История уже открыта.",
|
|
)
|
|
if changed:
|
|
await callback.answer()
|
|
except Exception as error:
|
|
await self.answer_callback_error(callback, error)
|
|
|
|
async def remind_to_call(
|
|
self,
|
|
callback: CallbackQuery,
|
|
binding: Binding
|
|
) -> None:
|
|
deal_id = (callback.data or "").split(":")[-1]
|
|
try:
|
|
await self.service.remind_to_call(binding, deal_id)
|
|
await callback.answer(
|
|
"Напоминание создано в Битриксе на час позже.",
|
|
show_alert=True
|
|
)
|
|
except Exception as error:
|
|
await self.answer_callback_error(callback, error)
|
|
|
|
async def send_deals_page(
|
|
self,
|
|
message: Message,
|
|
binding: Binding,
|
|
stage_key: str,
|
|
page: int
|
|
) -> None:
|
|
deal_page = await self.service.list_by_stage(binding, stage_key, page)
|
|
await message.answer(
|
|
self.page_text(deal_page),
|
|
reply_markup=DealKeyboards.deals_page(
|
|
deal_page.deals,
|
|
deal_page.stage_filters,
|
|
deal_page.stage_filter.key,
|
|
deal_page.page,
|
|
deal_page.has_next
|
|
),
|
|
parse_mode="HTML"
|
|
)
|
|
|
|
async def send_deal(self, message: Message, binding: Binding,
|
|
deal_id: str) -> None:
|
|
deal = await self.service.get(binding, deal_id)
|
|
if not deal:
|
|
await message.answer("Сделка не найдена.")
|
|
return
|
|
|
|
await message.answer(
|
|
DealFormatter.deal_details(deal),
|
|
reply_markup=DealKeyboards.deal_card(
|
|
deal,
|
|
binding.bitrix_user_id
|
|
),
|
|
parse_mode="HTML"
|
|
)
|
|
|
|
@staticmethod
|
|
def page_text(deal_page: DealPage) -> str:
|
|
title = DealFormatter.list_title(
|
|
deal_page.stage_filter,
|
|
deal_page.page,
|
|
deal_page.total_deals,
|
|
deal_page.total_pages
|
|
)
|
|
if not deal_page.deals:
|
|
return f"{title}\n\nСделки в этом фильтре не найдены."
|
|
return title
|
|
|
|
@staticmethod
|
|
async def edit_callback_message(
|
|
callback: CallbackQuery,
|
|
text: str,
|
|
reply_markup: InlineKeyboardMarkup,
|
|
unchanged_text: str
|
|
) -> bool:
|
|
if not isinstance(callback.message, Message):
|
|
await callback.answer("Не удалось обновить сообщение.",
|
|
show_alert=True)
|
|
return False
|
|
|
|
try:
|
|
await callback.message.edit_text(
|
|
text,
|
|
reply_markup=reply_markup,
|
|
parse_mode="HTML"
|
|
)
|
|
return True
|
|
|
|
except TelegramBadRequest as error:
|
|
if "message is not modified" in str(error).lower():
|
|
await callback.answer(unchanged_text)
|
|
return False
|
|
raise
|
|
|
|
@staticmethod
|
|
async def answer_error(message: Message, error: Exception) -> None:
|
|
if isinstance(error, httpx.HTTPStatusError):
|
|
logger.exception("Bitrix HTTP error")
|
|
await message.answer(
|
|
f"Ошибка HTTP Битрикс24: {error.response.status_code}")
|
|
elif isinstance(error, httpx.RequestError):
|
|
logger.exception("Bitrix connection error")
|
|
await message.answer("Не удалось подключиться к Битрикс24.")
|
|
elif isinstance(error, TelegramAPIError):
|
|
logger.exception("Telegram API error")
|
|
await message.answer("Telegram не смог выполнить действие.")
|
|
elif isinstance(error, RuntimeError):
|
|
logger.exception("Runtime error")
|
|
await message.answer(f"Ошибка: {html.escape(str(error))}")
|
|
else:
|
|
logger.exception("Unexpected bot error")
|
|
await message.answer("Произошла неизвестная ошибка.")
|
|
|
|
@classmethod
|
|
async def answer_callback_error(
|
|
cls,
|
|
callback: CallbackQuery,
|
|
error: Exception
|
|
) -> None:
|
|
if isinstance(callback.message, Message):
|
|
await cls.answer_error(callback.message, error)
|
|
try:
|
|
await callback.answer("Не удалось выполнить действие.",
|
|
show_alert=True)
|
|
except TelegramAPIError:
|
|
logger.exception("Failed to answer callback after error")
|