Files

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")