Добавление всех наработок за период практики.

This commit is contained in:
SkyForces
2026-07-24 00:46:19 +03:00
parent 24943b8a73
commit ae4141ba28
56 changed files with 3987 additions and 136 deletions
+3
View File
@@ -0,0 +1,3 @@
from .main import main
__all__ = ["main"]
+3
View File
@@ -0,0 +1,3 @@
from .main import main
main()
+88
View File
@@ -0,0 +1,88 @@
import hashlib
import re
from .database import BotDatabase
from .domain import Binding
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,100}$")
def hash_token(token: str) -> bytes:
return hashlib.sha256(token.encode("utf-8")).digest()
class BotBindingRepository:
"""Доступ бота только к функциям БД схемы binding.
Обертка над хранимыми функциями БД."""
def __init__(self, database: BotDatabase) -> None:
self.database = database
async def consume(
self,
token_hash: bytes,
telegram_user_id: int,
telegram_chat_id: int
) -> Binding | None:
async with self.database.transaction() as connection:
cursor = await connection.execute(
"""
SELECT *
FROM binding.consume_v1(%s, %s, %s)
""",
(token_hash, telegram_user_id, telegram_chat_id)
)
row = await cursor.fetchone()
return self._binding(row) if row else None
async def find(
self,
telegram_user_id: int,
member_id: str | None = None,
) -> Binding | None:
async with self.database.transaction() as connection:
cursor = await connection.execute(
"""
SELECT *
FROM binding.find_by_telegram_v1(%s, %s)
""",
(telegram_user_id, member_id)
)
row = await cursor.fetchone()
return self._binding(row) if row else None
@staticmethod
def _binding(row: dict) -> Binding:
return Binding(
member_id=str(row["member_id"]),
domain=str(row["domain"]),
bitrix_user_id=int(row["bitrix_user_id"]),
telegram_user_id=int(row["telegram_user_id"])
)
class BindingService:
"""Служба управления привязками пользователей."""
def __init__(
self,
repository: BotBindingRepository
) -> None:
self.repository = repository
async def consume(
self,
token: str,
telegram_user_id: int,
telegram_chat_id: int
) -> Binding | None:
if not TOKEN_PATTERN.fullmatch(token):
return None
return await self.repository.consume(
hash_token(token),
telegram_user_id,
telegram_chat_id
)
async def find(self, telegram_user_id: int) -> Binding | None:
return await self.repository.find(telegram_user_id)
+161
View File
@@ -0,0 +1,161 @@
import asyncio
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
from .crypto import TokenCipher
from .domain import Binding, OAuthCredentials
from .oauth import BotOAuthRepository
AUTH_ERRORS = {"expired_token", "invalid_token", "no_auth_found"}
class BitrixClient:
"""REST-клиент Битрикса с OAuth-контекстом привязанного пользователя."""
def __init__(
self,
credentials: BotOAuthRepository,
cipher: TokenCipher,
client_id: str,
client_secret: str,
oauth_token_url: str,
client: httpx.AsyncClient | None = None
) -> None:
self.credentials = credentials
self.cipher = cipher
self.client_id = client_id
self.client_secret = client_secret
self.oauth_token_url = oauth_token_url
# Передача клиента для упрощения тестирования.
self._client = client or httpx.AsyncClient(timeout=15)
self._owns_client = client is None
async def call(
self,
binding: Binding,
method: str,
params: dict[str, Any] | None = None
) -> dict[str, Any]:
credentials = await self.credentials.get(binding)
if not credentials:
raise RuntimeError("OAuth-данные пользователя не найдены")
# Отправляем запрос и в случае истечения токена запрашиваем обновление.
data = await self._request(credentials, method, params)
if str(data.get("error") or "").lower() in AUTH_ERRORS:
credentials = await self._refresh(credentials, binding)
data = await self._request(credentials, method, params)
if "error" in data:
description = data.get("error_description", data["error"])
raise RuntimeError(f"Bitrix API error: {description}")
return data
async def _request(
self,
credentials: OAuthCredentials,
method: str,
params: dict[str, Any] | None
) -> dict[str, Any]:
payload = dict(params or {})
payload["auth"] = self.cipher.decrypt(credentials.access_token)
response = await self._client.post(
f"https://{credentials.domain}/rest/{method}.json",
json=payload
)
try:
data = response.json()
except ValueError:
response.raise_for_status()
raise RuntimeError("Bitrix вернул некорректный ответ") from None
# Битрикс присылает полезное описание ошибки и при HTTP 4xx.
if response.is_error and "error" not in data:
response.raise_for_status()
return data
async def _refresh(
self,
credentials: OAuthCredentials,
binding: Binding
) -> OAuthCredentials:
# Если другой процесс уже обновляет токен, ждем его завершения.
if not await self.credentials.claim_refresh(credentials):
return await self._wait_for_refresh(credentials, binding)
try:
# Битрикс возвращает новую пару, поэтому обновляем оба токена.
try:
response = await self._client.get(
self.oauth_token_url,
params={
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": self.cipher.decrypt(
credentials.refresh_token)
}
)
response.raise_for_status()
except httpx.HTTPError:
# Не включаем URL с OAuth-секретами в traceback.
raise RuntimeError("Не удалось обновить OAuth-токен") from None
data = response.json()
if "error" in data:
raise RuntimeError(
"Bitrix OAuth error: "
+ str(data.get("error_description") or data["error"])
)
# Проверяем, что обновленный токен принадлежит тому же порталу
# и пользователю.
if data.get("member_id") not in {None, credentials.member_id}:
raise RuntimeError("Bitrix вернул токен другого портала")
if int(data.get("user_id", credentials.bitrix_user_id)) != (
credentials.bitrix_user_id
):
raise RuntimeError("Bitrix вернул токен другого пользователя")
# Обновляем токены в базе и возвращаем новые данные.
expires_at = datetime.now(UTC) + timedelta(
seconds=int(data.get("expires_in", 3600))
)
saved = await self.credentials.finish_refresh(
credentials,
self.cipher.encrypt(str(data["access_token"])),
self.cipher.encrypt(str(data["refresh_token"])),
expires_at
)
# Если другой процесс успел обновить токен, ждем его завершения.
if not saved:
return await self._wait_for_refresh(credentials, binding)
updated = await self.credentials.get(binding)
if not updated:
raise RuntimeError("Обновленные OAuth-данные не найдены")
return updated
except Exception:
await self.credentials.release_refresh(credentials)
raise
async def _wait_for_refresh(
self,
previous: OAuthCredentials,
binding: Binding
) -> OAuthCredentials:
for _ in range(80):
await asyncio.sleep(0.2)
current = await self.credentials.get(binding)
if current and current.version > previous.version:
return current
raise RuntimeError("Не удалось дождаться обновления OAuth-токена")
async def close(self) -> None:
if self._owns_client:
await self._client.aclose()
+42
View File
@@ -0,0 +1,42 @@
import os
from dataclasses import dataclass
DEFAULT_TAKE_TO_WORK_STAGE_ID = "PREPARATION"
def _required(name: str) -> str:
value = os.getenv(name)
if not value:
raise RuntimeError(f"{name} is not set")
return value
@dataclass(frozen=True)
class BotConfig:
"""Настройки процесса Telegram-бота."""
bot_token: str
database_url: str
bitrix_client_id: str
bitrix_client_secret: str
token_encryption_key: str
oauth_token_url: str
take_to_work_stage_id: str = DEFAULT_TAKE_TO_WORK_STAGE_ID
@classmethod
def from_env(cls) -> "BotConfig":
return cls(
bot_token=_required("BOT_TOKEN"),
database_url=_required("DATABASE_URL"),
bitrix_client_id=_required("BITRIX_CLIENT_ID"),
bitrix_client_secret=_required("BITRIX_CLIENT_SECRET"),
token_encryption_key=_required("TOKEN_ENCRYPTION_KEY"),
oauth_token_url=os.getenv(
"BITRIX_OAUTH_TOKEN_URL",
"https://oauth.bitrix.info/oauth/token/",
),
take_to_work_stage_id=os.getenv(
"BITRIX_TAKE_TO_WORK_STAGE_ID",
DEFAULT_TAKE_TO_WORK_STAGE_ID
)
)
+20
View File
@@ -0,0 +1,20 @@
from cryptography.fernet import Fernet, InvalidToken
class TokenCipher:
"""Шифрует OAuth-токены перед хранением в БД."""
def __init__(self, key: str) -> None:
try:
self._fernet = Fernet(key.encode("ascii"))
except (ValueError, UnicodeEncodeError) as error:
raise RuntimeError("TOKEN_ENCRYPTION_KEY is invalid") from error
def encrypt(self, value: str) -> bytes:
return self._fernet.encrypt(value.encode("utf-8"))
def decrypt(self, value: bytes) -> str:
try:
return self._fernet.decrypt(value).decode("utf-8")
except InvalidToken as error:
raise RuntimeError("Не удалось расшифровать OAuth-токен") from error
+38
View File
@@ -0,0 +1,38 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from psycopg import AsyncConnection
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
class BotDatabase:
"""Пул соединений БД для асинхронного процесса бота."""
def __init__(self, database_url: str) -> None:
# Пул может содержать в себе максимум 5 соединений.
self.pool = AsyncConnectionPool(
conninfo=database_url,
min_size=1,
max_size=5,
open=False,
# Фабрика для представления строк БД как словарей.
kwargs={"row_factory": dict_row}
)
async def open(self) -> None:
await self.pool.open(wait=True)
async def close(self) -> None:
await self.pool.close()
@asynccontextmanager
async def transaction(self) -> AsyncGenerator[AsyncConnection]:
async with self.pool.connection() as connection:
async with connection.transaction():
yield connection
async def ping(self) -> bool:
async with self.pool.connection() as connection:
cursor = await connection.execute("SELECT 1")
return await cursor.fetchone() is not None
+641
View File
@@ -0,0 +1,641 @@
import asyncio
import logging
import time
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from .bitrix import BitrixClient
from .domain import (
DEALS_PER_PAGE,
Binding,
ClientInfo,
DealPage,
DealStage,
DealStageAdvance,
DealStageFilter,
)
logger = logging.getLogger(__name__)
class DealAssignmentConflict(RuntimeError):
"""Ответственный изменился после показа карточки."""
class DealAdvanceForbidden(RuntimeError):
"""Стадию может менять только ответственный за сделку."""
class DealStageConflict(RuntimeError):
"""Стадия изменилась после запроса комментария."""
class DealCommentSaveError(RuntimeError):
"""Стадия изменена, но комментарий не добавлен."""
def __init__(self, advance: DealStageAdvance) -> None:
super().__init__(
"Стадия изменена, но комментарий не удалось сохранить."
)
self.advance = advance
@dataclass
class _DealLockEntry:
lock: asyncio.Lock
users: int = 0
class DealService:
"""Загрузка и изменение сделок Bitrix."""
deal_select = [
"ID",
"TITLE",
"STAGE_ID",
"IS_NEW",
"OPPORTUNITY",
"CURRENCY_ID",
"DATE_CREATE",
"ASSIGNED_BY_ID",
"CONTACT_ID",
"COMPANY_ID",
"SOURCE_ID",
"COMMENTS",
]
def __init__(
self,
bitrix: BitrixClient,
take_to_work_stage_id: str
) -> None:
self.bitrix = bitrix
self.take_to_work_stage_id = take_to_work_stage_id
self._deal_locks: dict[tuple[str, str], _DealLockEntry] = {}
self._deal_locks_guard = asyncio.Lock()
self._stage_cache: dict[
tuple[str, int, int], tuple[float, tuple[DealStage, ...]]
] = {}
async def list_by_stage(
self,
binding: Binding,
stage_key: str = "new",
page: int = 0,
limit: int = DEALS_PER_PAGE
) -> DealPage:
stage_filters = await self.stage_filters(binding)
stage_filter = self._select_stage_filter(stage_filters, stage_key)
bitrix_filter = {}
if stage_filter.stage_id:
bitrix_filter["STAGE_ID"] = stage_filter.stage_id
if stage_filter.assigned_to_viewer:
bitrix_filter["ASSIGNED_BY_ID"] = binding.bitrix_user_id
page = max(page, 0)
start_index = page * limit
end_index = start_index + limit
loaded_deals = []
bitrix_start: int | None = 0
total_deals = 0
# Битрикс и Telegram используют страницы разного размера.
while bitrix_start is not None and len(loaded_deals) < end_index:
# https://apidocs.bitrix24.ru/api-reference/crm/deals/crm-deal-list.html
data = await self.bitrix.call(
binding,
"crm.deal.list",
{
"order": {"DATE_CREATE": "DESC"},
"filter": bitrix_filter,
"select": self.deal_select,
"start": bitrix_start
}
)
loaded_deals.extend(data.get("result", []))
total_deals = int(data.get("total", len(loaded_deals)))
bitrix_start = data.get("next")
deals = loaded_deals[start_index:end_index]
total_pages = max(1, (total_deals + limit - 1) // limit)
return DealPage(
deals,
page,
total_deals,
total_pages,
stage_filter,
stage_filters
)
async def stage_filters(
self,
binding: Binding
) -> tuple[DealStageFilter, ...]:
stages = await self._stages(binding, category_id=0)
filters = [
DealStageFilter(
key="mine",
title="Мои сделки",
stage_id=None,
assigned_to_viewer=True,
)
]
filters.extend(
DealStageFilter(stage.stage_id, stage.title, stage.stage_id)
for stage in stages
)
filters.append(DealStageFilter("all", "Все", None))
return tuple(filters)
def _select_stage_filter(
self,
filters: tuple[DealStageFilter, ...],
stage_key: str
) -> DealStageFilter:
initial = next(
(item for item in filters if item.stage_id is not None),
filters[-1]
)
# Callback `new` означает первую стадию, полученную из Битрикса.
if stage_key == "new":
return initial
selected = next((item for item in filters if item.key == stage_key),
None)
return selected or initial
async def _stages(
self,
binding: Binding,
category_id: int
) -> tuple[DealStage, ...]:
key = (binding.member_id, binding.bitrix_user_id, category_id)
cached = self._stage_cache.get(key)
if cached and cached[0] > time.monotonic():
return cached[1]
entity_id = "DEAL_STAGE" if category_id == 0 else (
f"DEAL_STAGE_{category_id}"
)
items = []
bitrix_start: int | None = 0
# https://apidocs.bitrix24.ru/api-reference/crm/status/crm-status-list.html
while bitrix_start is not None:
data = await self.bitrix.call(
binding,
"crm.status.list",
{
"order": {"SORT": "ASC"},
"filter": {"ENTITY_ID": entity_id},
"start": bitrix_start
}
)
items.extend(data.get("result", []))
bitrix_start = data.get("next")
stages = []
seen_stage_ids = set()
for item in items:
raw_stage_id = str(item.get("STATUS_ID") or "")
if not raw_stage_id:
continue
stage_id = raw_stage_id
prefix = f"C{category_id}:"
if category_id and not stage_id.startswith(prefix):
stage_id = prefix + stage_id
if stage_id in seen_stage_ids:
continue
semantics = self._stage_semantics(item)
stages.append(
DealStage(
stage_id=stage_id,
title=str(item.get("NAME") or raw_stage_id),
semantics=semantics,
)
)
seen_stage_ids.add(stage_id)
result = tuple(stages)
self._stage_cache[key] = (time.monotonic() + 300, result)
return result
async def _stage_map(
self,
binding: Binding,
category_id: int
) -> dict[str, str]:
stages = await self._stages(binding, category_id)
return {stage.stage_id: stage.title for stage in stages}
@staticmethod
def _stage_semantics(item: dict) -> str:
semantics = str(item.get("SEMANTICS") or "").upper()
if semantics in {"P", "S", "F"}:
return semantics
extra_semantics = str(
(item.get("EXTRA") or {}).get("SEMANTICS") or ""
).lower()
return {
"process": "P",
"success": "S",
"failure": "F",
}.get(extra_semantics, "P")
async def stage_name(
self,
binding: Binding,
category_id: int,
stage_id: str
) -> str | None:
stages = await self._stage_map(binding, category_id)
return stages.get(stage_id)
async def get(self, binding: Binding, deal_id: str) -> dict | None:
# https://apidocs.bitrix24.ru/api-reference/crm/deals/crm-deal-get.html
data = await self.bitrix.call(binding, "crm.deal.get", {"id": deal_id})
deal = data.get("result")
if not deal:
return None
client = await self.get_client_info(binding, deal)
deal["CLIENT_NAME"] = client.name
deal["CLIENT_COMPANY"] = client.company
deal["CLIENT_PHONE"] = client.phone
deal["SOURCE_NAME"] = await self.get_source_name(
binding, str(deal.get("SOURCE_ID") or "")
)
deal["STAGE_NAME"] = await self.stage_name(
binding,
int(deal.get("CATEGORY_ID") or 0),
str(deal.get("STAGE_ID") or "")
)
next_stage = await self._next_stage(binding, deal)
if next_stage:
deal["NEXT_STAGE_ID"] = next_stage.stage_id
deal["NEXT_STAGE_NAME"] = next_stage.title
deal["NEXT_STAGE_IS_FINAL"] = next_stage.is_final
return deal
async def prepare_stage_advance(
self,
binding: Binding,
deal_id: str
) -> DealStageAdvance:
deal = await self._get_raw(binding, deal_id)
if not deal:
raise RuntimeError("Сделка не найдена")
self._ensure_responsible(binding, deal)
next_stage = await self._next_stage(binding, deal)
if not next_stage:
raise RuntimeError("Сделка уже находится на финальной стадии")
return DealStageAdvance(
deal_id=deal_id,
current_stage_id=str(deal.get("STAGE_ID") or ""),
target_stage_id=next_stage.stage_id,
target_stage_title=next_stage.title,
is_final=next_stage.is_final,
)
async def advance_stage(
self,
binding: Binding,
deal_id: str,
expected_stage_id: str,
expected_target_stage_id: str,
comment: str | None,
) -> DealStageAdvance:
async with self._deal_lock(binding, deal_id):
current = await self._get_raw(binding, deal_id)
if not current:
raise RuntimeError("Сделка не найдена")
self._ensure_responsible(binding, current)
current_stage_id = str(current.get("STAGE_ID") or "")
if current_stage_id != expected_stage_id:
raise DealStageConflict(
"Стадия уже изменилась. Обновите карточку сделки."
)
next_stage = await self._next_stage(binding, current)
if (
not next_stage
or next_stage.stage_id != expected_target_stage_id
):
raise DealStageConflict(
"Набор стадий изменился. Откройте сделку заново."
)
advance = DealStageAdvance(
deal_id=deal_id,
current_stage_id=current_stage_id,
target_stage_id=next_stage.stage_id,
target_stage_title=next_stage.title,
is_final=next_stage.is_final,
)
# https://apidocs.bitrix24.ru/api-reference/crm/deals/crm-deal-update.html
await self.bitrix.call(
binding,
"crm.deal.update",
{
"id": deal_id,
"fields": {"STAGE_ID": next_stage.stage_id},
"params": {"REGISTER_HISTORY_EVENT": "Y"},
},
)
updated = await self._get_raw(binding, deal_id)
if str((updated or {}).get(
"STAGE_ID") or "") != next_stage.stage_id:
raise DealStageConflict(
"Стадия изменилась одновременно с обновлением."
)
if comment:
try:
# Комментарий добавляется в таймлайн, не затирая COMMENTS.
await self.bitrix.call(
binding,
"crm.timeline.comment.add",
{
"fields": {
"ENTITY_ID": int(deal_id),
"ENTITY_TYPE": "deal",
"COMMENT": comment,
}
},
)
except Exception as error:
raise DealCommentSaveError(advance) from error
return advance
async def _next_stage(
self,
binding: Binding,
deal: dict
) -> DealStage | None:
current_stage_id = str(deal.get("STAGE_ID") or "")
current_semantics = str(
deal.get("STAGE_SEMANTIC_ID") or ""
).upper()
if current_semantics in {"S", "F"}:
return None
stages = await self._stages(
binding,
int(deal.get("CATEGORY_ID") or 0),
)
current_index = next(
(
index
for index, stage in enumerate(stages)
if stage.stage_id == current_stage_id
),
None,
)
if current_index is None or stages[current_index].is_final:
return None
if current_index + 1 >= len(stages):
return None
return stages[current_index + 1]
@staticmethod
def _ensure_responsible(binding: Binding, deal: dict) -> None:
responsible_id = str(deal.get("ASSIGNED_BY_ID") or "")
if responsible_id != str(binding.bitrix_user_id):
raise DealAdvanceForbidden(
"Переводить сделку может только ответственный за нее."
)
async def history(
self,
binding: Binding,
deal_id: str,
limit: int = 5
) -> list[dict]:
# https://apidocs.bitrix24.ru/api-reference/crm/crm-stage-history-list.html
data = await self.bitrix.call(
binding,
"crm.stagehistory.list",
{
"entityTypeId": 2,
"order": {"ID": "DESC"},
"filter": {"OWNER_ID": int(deal_id)},
"select": [
"ID",
"TYPE_ID",
"CATEGORY_ID",
"STAGE_ID",
"CREATED_TIME"
],
"start": 0
}
)
result = data.get("result") or {}
events = list(result.get("items") or [])[:limit]
for event in events:
event["STAGE_NAME"] = await self.stage_name(
binding,
int(event.get("CATEGORY_ID") or 0),
str(event.get("STAGE_ID") or "")
)
return events
async def remind_to_call(
self,
binding: Binding,
deal_id: str
) -> datetime:
deadline = datetime.now(UTC) + timedelta(hours=1)
# https://apidocs.bitrix24.ru/api-reference/crm/timeline/activities/todo/crm-activity-todo-add.html
await self.bitrix.call(
binding,
"crm.activity.todo.add",
{
"ownerTypeId": 2,
"ownerId": int(deal_id),
"deadline": deadline.isoformat(),
"title": "Позвонить клиенту",
"description": f"Отложенный звонок по сделке #{deal_id}",
"responsibleId": binding.bitrix_user_id,
"pingOffsets": [0]
}
)
return deadline
async def take_to_work(
self,
binding: Binding,
deal_id: str,
expected_responsible_id: str
) -> bool:
async with self._deal_lock(binding, deal_id):
current = await self._get_raw(binding, deal_id)
if not current:
raise RuntimeError("Сделка не найдена")
responsible_id = str(current.get("ASSIGNED_BY_ID") or "")
target_id = str(binding.bitrix_user_id)
if responsible_id == target_id:
return False
if responsible_id != expected_responsible_id:
raise DealAssignmentConflict(
"Ответственный уже изменился. Обновите карточку сделки."
)
target_stage_id = await self._take_to_work_stage_id(
binding,
int(current.get("CATEGORY_ID") or 0)
)
# https://apidocs.bitrix24.com/api-reference/crm/deals/crm-deal-update.html
await self.bitrix.call(
binding,
"crm.deal.update",
{
"id": deal_id,
"fields": {
"ASSIGNED_BY_ID": binding.bitrix_user_id,
"STAGE_ID": target_stage_id
},
"params": {"REGISTER_HISTORY_EVENT": "Y"}
},
)
# REST Bitrix не поддерживает условный UPDATE, поэтому проверяем результат.
updated = await self._get_raw(binding, deal_id)
if str((updated or {}).get("ASSIGNED_BY_ID") or "") != target_id:
raise DealAssignmentConflict(
"Ответственный изменился одновременно с назначением."
)
return True
async def _take_to_work_stage_id(
self,
binding: Binding,
category_id: int
) -> str:
stages = await self._stage_map(binding, category_id)
candidates = [self.take_to_work_stage_id]
if category_id and ":" not in self.take_to_work_stage_id:
candidates.append(
f"C{category_id}:{self.take_to_work_stage_id}"
)
target = next((item for item in candidates if item in stages), None)
if target:
return target
raise RuntimeError(
"Стадия для взятия в работу "
f"{self.take_to_work_stage_id} не найдена в Битриксе"
)
async def _get_raw(self, binding: Binding, deal_id: str) -> dict | None:
data = await self.bitrix.call(binding, "crm.deal.get", {"id": deal_id})
return data.get("result") or None
@asynccontextmanager
async def _deal_lock(
self,
binding: Binding,
deal_id: str
) -> AsyncGenerator[None]:
key = (binding.member_id, deal_id)
async with self._deal_locks_guard:
entry = self._deal_locks.get(key)
if entry is None:
entry = _DealLockEntry(asyncio.Lock())
self._deal_locks[key] = entry
entry.users += 1
await entry.lock.acquire()
try:
yield
finally:
entry.lock.release()
async with self._deal_locks_guard:
entry.users -= 1
if entry.users == 0:
self._deal_locks.pop(key, None)
async def get_client_info(self, binding: Binding, deal: dict) -> ClientInfo:
contact = None
company = None
contact_id = str(deal.get("CONTACT_ID") or "")
if contact_id:
# https://apidocs.bitrix24.com/api-reference/crm/contacts/crm-contact-get.html
contact = await self._entity(binding, "crm.contact.get", contact_id)
company_id = str(deal.get("COMPANY_ID") or "")
if company_id:
# https://apidocs.bitrix24.com/api-reference/crm/companies/crm-company-get.html
company = await self._entity(binding, "crm.company.get", company_id)
return ClientInfo(
name=self._contact_name(contact),
company=self._company_name(company),
phone=(
self._phone_from_entity(contact) or self._phone_from_entity(
company)
)
)
async def get_source_name(self, binding: Binding,
source_id: str) -> str | None:
if not source_id:
return None
try:
# https://apidocs.bitrix24.ru/api-reference/crm/status/crm-status-list.html
data = await self.bitrix.call(
binding,
"crm.status.list",
{
"filter": {
"ENTITY_ID": "SOURCE",
"STATUS_ID": source_id
}
}
)
except Exception:
logger.exception("Failed to load Bitrix source name")
return None
sources = data.get("result", [])
return str(sources[0].get("NAME") or "") or None if sources else None
async def _entity(self, binding: Binding, method: str,
entity_id: str) -> dict:
data = await self.bitrix.call(binding, method, {"id": entity_id})
return data.get("result", {}) or {}
@staticmethod
def _phone_from_entity(entity: dict | None) -> str | None:
phones = (entity or {}).get("PHONE") or []
return str(phones[0].get("VALUE") or "") or None if phones else None
@staticmethod
def _contact_name(contact: dict | None) -> str | None:
if not contact:
return None
parts = [
str(contact.get("LAST_NAME") or "").strip(),
str(contact.get("NAME") or "").strip(),
str(contact.get("SECOND_NAME") or "").strip()
]
return " ".join(part for part in parts if part) or None
@staticmethod
def _company_name(company: dict | None) -> str | None:
if not company:
return None
return str(company.get("TITLE") or "").strip() or None
+73
View File
@@ -0,0 +1,73 @@
from dataclasses import dataclass
from datetime import datetime
DEALS_PER_PAGE = 5
MAX_DEAL_MESSAGE_LENGTH = 3900
@dataclass(frozen=True)
class Binding:
member_id: str
domain: str
bitrix_user_id: int
telegram_user_id: int
@dataclass(frozen=True)
class OAuthCredentials:
member_id: str
domain: str
bitrix_user_id: int
access_token: bytes
refresh_token: bytes
expires_at: datetime
version: int
@dataclass(frozen=True)
class ClientInfo:
name: str | None = None
company: str | None = None
phone: str | None = None
@dataclass(frozen=True)
class DealStageFilter:
key: str
title: str
stage_id: str | None
assigned_to_viewer: bool = False
@dataclass(frozen=True)
class DealStage:
stage_id: str
title: str
semantics: str
@property
def is_final(self) -> bool:
return self.semantics in {"S", "F"}
@dataclass(frozen=True)
class DealStageAdvance:
deal_id: str
current_stage_id: str
target_stage_id: str
target_stage_title: str
is_final: bool
@dataclass(frozen=True)
class DealPage:
deals: list[dict]
page: int
total_deals: int
total_pages: int
stage_filter: DealStageFilter
stage_filters: tuple[DealStageFilter, ...]
@property
def has_next(self) -> bool:
return self.page + 1 < self.total_pages
+531
View File
@@ -0,0 +1,531 @@
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")
+57
View File
@@ -0,0 +1,57 @@
import asyncio
import logging
from aiogram import Bot, Dispatcher
from dotenv import load_dotenv
from .binding import BindingService, BotBindingRepository
from .bitrix import BitrixClient
from .config import BotConfig
from .crypto import TokenCipher
from .database import BotDatabase
from .deals import DealService
from .handlers import DealBotHandlers, StartBotHandlers
from .oauth import BotOAuthRepository
async def run() -> None:
load_dotenv()
logging.basicConfig(level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
config = BotConfig.from_env()
database = BotDatabase(config.database_url)
await database.open()
bitrix = BitrixClient(
BotOAuthRepository(database),
TokenCipher(config.token_encryption_key),
config.bitrix_client_id,
config.bitrix_client_secret,
config.oauth_token_url,
)
bindings = BindingService(BotBindingRepository(database))
deals = DealService(
bitrix,
config.take_to_work_stage_id,
)
dispatcher = Dispatcher()
dispatcher.include_router(StartBotHandlers(bindings).router)
dispatcher.include_router(DealBotHandlers(deals, bindings).router)
try:
await dispatcher.start_polling(Bot(token=config.bot_token))
finally:
await bitrix.close()
await database.close()
def main() -> None:
asyncio.run(run())
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, TelegramObject
from .binding import BindingService
class BindingRequiredMiddleware(BaseMiddleware):
"""Не пропускает CRM-команды до привязки аккаунта."""
def __init__(self, bindings: BindingService) -> None:
self.bindings = bindings
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any]
) -> Any:
user = data.get("event_from_user")
if user:
binding = await self.bindings.find(user.id)
if binding:
data["binding"] = binding
return await handler(event, data)
text = "Сначала привяжи Telegram через приложение в Bitrix24."
if isinstance(event, CallbackQuery):
await event.answer(text, show_alert=True)
elif isinstance(event, Message):
await event.answer(text)
return None
+84
View File
@@ -0,0 +1,84 @@
from datetime import datetime
from .database import BotDatabase
from .domain import Binding, OAuthCredentials
class BotOAuthRepository:
"""Транзакционные операции с OAuth-данными Битрикса.
Обертка над хранимыми функциями БД."""
def __init__(self, database: BotDatabase) -> None:
self.database = database
async def get(self, binding: Binding) -> OAuthCredentials | None:
async with self.database.transaction() as connection:
cursor = await connection.execute(
"SELECT * FROM oauth.get_credentials_v1(%s, %s)",
(binding.member_id, binding.bitrix_user_id)
)
row = await cursor.fetchone()
# pyrefly: ignore [bad-argument-type]
return self._credentials(row) if row else None
async def claim_refresh(self, credentials: OAuthCredentials) -> bool:
async with self.database.transaction() as connection:
cursor = await connection.execute(
"SELECT oauth.claim_refresh_v1(%s, %s, %s)",
(
credentials.member_id,
credentials.bitrix_user_id,
credentials.version
)
)
row = await cursor.fetchone()
# pyrefly: ignore [missing-attribute]
return bool(row and next(iter(row.values())))
async def finish_refresh(
self,
credentials: OAuthCredentials,
access_token: bytes,
refresh_token: bytes,
expires_at: datetime
) -> bool:
async with self.database.transaction() as connection:
cursor = await connection.execute(
"SELECT oauth.finish_refresh_v1(%s, %s, %s, %s, %s, %s)",
(
credentials.member_id,
credentials.bitrix_user_id,
credentials.version,
access_token,
refresh_token,
expires_at
)
)
row = await cursor.fetchone()
# pyrefly: ignore [missing-attribute]
return bool(row and next(iter(row.values())))
async def release_refresh(self, credentials: OAuthCredentials) -> None:
async with self.database.transaction() as connection:
await connection.execute(
"SELECT oauth.release_refresh_v1(%s, %s, %s)",
(
credentials.member_id,
credentials.bitrix_user_id,
credentials.version
)
)
@staticmethod
def _credentials(row: dict) -> OAuthCredentials:
return OAuthCredentials(
member_id=str(row["member_id"]),
domain=str(row["domain"]),
bitrix_user_id=int(row["bitrix_user_id"]),
access_token=bytes(row["access_token"]),
refresh_token=bytes(row["refresh_token"]),
expires_at=row["expires_at"],
version=int(row["version"])
)
+272
View File
@@ -0,0 +1,272 @@
import decimal
import html
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from .domain import (
MAX_DEAL_MESSAGE_LENGTH,
DealStageFilter,
)
class DealFormatter:
"""Тексты карточек и списков Telegram."""
@staticmethod
def truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: limit - 3].rstrip() + "..."
@staticmethod
def list_title(
stage_filter: DealStageFilter,
page: int,
total_deals: int,
total_pages: int
) -> str:
title = html.escape(stage_filter.title)
return (
f"<b>Сделки: {title}</b>\n"
f"Всего сделок: <b>{total_deals}</b>\n"
f"Страница <b>{page + 1}</b> из <b>{total_pages}</b>"
)
@staticmethod
def money(value: Any, currency: Any) -> str:
try:
amount = decimal.Decimal(str(value or "0"))
formatted = f"{amount:,.2f}".replace(",", " ")
except decimal.InvalidOperation:
formatted = html.escape(str(value or "0"))
return f"{formatted} {html.escape(str(currency or ''))}".strip()
@classmethod
def deal_summary(cls, deal: dict) -> str:
summary = " · ".join(
(
f"#{deal.get('ID', '-')}",
str(deal.get("TITLE") or "Без названия"),
cls.money(deal.get("OPPORTUNITY"), deal.get("CURRENCY_ID"))
)
)
return cls.truncate(summary, 60)
@classmethod
def deal_details(cls, deal: dict) -> str:
deal_id = html.escape(str(deal.get("ID", "-")))
title = html.escape(str(deal.get("TITLE") or "Без названия"))
stage = html.escape(
str(deal.get("STAGE_NAME") or deal.get("STAGE_ID") or "-")
)
source = html.escape(
str(deal.get("SOURCE_NAME") or deal.get("SOURCE_ID") or "-")
)
assigned = html.escape(str(deal.get("ASSIGNED_BY_ID") or "не назначен"))
date = html.escape(str(deal.get("DATE_CREATE") or "-"))
client = html.escape(str(deal.get("CLIENT_NAME") or "не указан"))
company = html.escape(str(deal.get("CLIENT_COMPANY") or ""))
phone = html.escape(str(deal.get("CLIENT_PHONE") or "не найден"))
comments = html.escape(str(deal.get("COMMENTS") or "")).strip()
lines = [
f"<b>Сделка #{deal_id}</b>",
f"<b>{title}</b>",
"",
f"Клиент: <code>{client}</code>",
*([f"Компания: <code>{company}</code>"] if company else []),
f"Телефон клиента: <code>{phone}</code>",
f"Стадия: <code>{stage}</code>",
f"Источник сделки: <code>{source}</code>",
f"Сумма: {cls.money(deal.get('OPPORTUNITY'), deal.get('CURRENCY_ID'))}",
f"Ответственный: <code>{assigned}</code>",
f"Дата создания: <code>{date}</code>"
]
if comments:
lines.extend(["", f"<b>Комментарий:</b>\n{comments}"])
return cls.truncate("\n".join(lines), MAX_DEAL_MESSAGE_LENGTH)
@classmethod
def deal_history(cls, deal_id: str, events: list[dict]) -> str:
lines = [f"<b>История сделки #{html.escape(deal_id)}</b>"]
if not events:
lines.extend(["", "Изменения стадий пока не найдены."])
return "\n".join(lines)
event_names = {
"1": "Сделка создана",
"2": "Переход на стадию",
"3": "Переход на финальную стадию",
"5": "Изменение воронки"
}
for event in events:
date = html.escape(
str(event.get("CREATED_TIME") or "дата не указана"))
event_type = str(event.get("TYPE_ID") or "")
name = event_names.get(event_type, "Изменение стадии")
stage = html.escape(str(
event.get("STAGE_NAME")
or event.get("STAGE_ID")
or "не указана"
))
lines.extend(
[
"",
f"• <b>{name}</b>",
f" Стадия: <code>{stage}</code>",
f" {date}"
]
)
return cls.truncate("\n".join(lines), MAX_DEAL_MESSAGE_LENGTH)
class DealKeyboards:
@staticmethod
def deals_page(
deals: list[dict],
stage_filters: tuple[DealStageFilter, ...],
stage_key: str,
page: int,
has_next: bool
) -> InlineKeyboardMarkup:
filter_buttons = [
InlineKeyboardButton(
text=("" if stage.key == stage_key else "") + stage.title,
callback_data=f"deals:page:{stage.key}:0"
)
for stage in stage_filters
]
rows = [
[
InlineKeyboardButton(
text=DealFormatter.deal_summary(deal),
callback_data=f"deal:view:{deal['ID']}"
)
]
for deal in deals
]
navigation = []
if page > 0:
navigation.append(
InlineKeyboardButton(
text="Назад",
callback_data=f"deals:page:{stage_key}:{page - 1}"
)
)
if has_next:
navigation.append(
InlineKeyboardButton(
text="Вперед",
callback_data=f"deals:page:{stage_key}:{page + 1}"
)
)
if navigation:
rows.append(navigation)
rows.append(
[
InlineKeyboardButton(
text="Обновить",
callback_data=f"deals:page:{stage_key}:{page}"
)
]
)
rows.extend(
filter_buttons[index: index + 2]
for index in range(0, len(filter_buttons), 2)
)
return InlineKeyboardMarkup(inline_keyboard=rows)
@staticmethod
def deal_card(
deal: dict,
viewer_bitrix_user_id: int
) -> InlineKeyboardMarkup:
deal_id = str(deal["ID"])
responsible_id = str(deal.get("ASSIGNED_BY_ID") or "")
rows = []
if (
str(deal.get("IS_NEW") or "").upper() == "Y"
and responsible_id != str(viewer_bitrix_user_id)
):
rows.append(
[
InlineKeyboardButton(
text="Стать ответственным и взять в работу",
callback_data=(
f"deal:assign:{deal_id}:{responsible_id}")
)
]
)
if (
responsible_id == str(viewer_bitrix_user_id)
and deal.get("NEXT_STAGE_ID")
):
next_stage_name = DealFormatter.truncate(
str(deal.get("NEXT_STAGE_NAME") or "следующая стадия"),
42,
)
if deal.get("NEXT_STAGE_IS_FINAL"):
button_text = f"Завершить: {next_stage_name}"
else:
button_text = f"Следующая стадия: {next_stage_name}"
rows.append(
[
InlineKeyboardButton(
text=button_text,
callback_data=f"deal:advance:{deal_id}",
)
]
)
rows.append(
[
InlineKeyboardButton(
text="Позвонить позже",
callback_data=f"deal:remind:{deal_id}"
)
]
)
rows.append(
[
InlineKeyboardButton(
text="История изменений",
callback_data=f"deal:history:{deal_id}"
)
]
)
rows.append(
[
InlineKeyboardButton(
text="К списку сделок",
callback_data="deals:page:new:0"
)
]
)
return InlineKeyboardMarkup(inline_keyboard=rows)
@staticmethod
def deal_history(deal_id: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="К сделке",
callback_data=f"deal:view:{deal_id}"
)
],
[
InlineKeyboardButton(
text="К списку сделок",
callback_data="deals:page:new:0"
)
],
]
)