Добавление всех наработок за период практики.
@@ -0,0 +1,20 @@
|
||||
.git/
|
||||
.agents/
|
||||
.codex/
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
tests
|
||||
docs
|
||||
.ruff_cache/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
|
||||
db/
|
||||
@@ -0,0 +1,27 @@
|
||||
# PostgreSQL
|
||||
POSTGRES_DB=bitrix_bot
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=change_admin_password
|
||||
SITE_DB_PASSWORD=change_site_password
|
||||
BOT_DB_PASSWORD=change_bot_password
|
||||
|
||||
# Внешний nginx проксирует на 127.0.0.1:8000.
|
||||
PUBLIC_BASE_URL=https://bot.example.ru
|
||||
SITE_PUBLISHED_PORT=8000
|
||||
SITE_PORT=8000
|
||||
SITE_WORKERS=2
|
||||
|
||||
# Локальное приложение Битрикса. Нужно будет заменить ID и SECRET на реальные
|
||||
# значения, полученные при регистрации приложения в Битриксе.
|
||||
BITRIX_CLIENT_ID=local.example
|
||||
BITRIX_CLIENT_SECRET=change_me
|
||||
BITRIX_OAUTH_TOKEN_URL=https://oauth.bitrix.info/oauth/token/
|
||||
BITRIX_TAKE_TO_WORK_STAGE_ID=PREPARATION
|
||||
|
||||
# Telegram
|
||||
BOT_TOKEN=change_me
|
||||
BOT_USERNAME=example_bot
|
||||
BINDING_TOKEN_TTL_SECONDS=600
|
||||
|
||||
# Ключ шифрования токенов. Должен быть 32 байта в base64.
|
||||
TOKEN_ENCRYPTION_KEY=change_me
|
||||
@@ -0,0 +1,7 @@
|
||||
* text=auto
|
||||
|
||||
*.sh text eol=lf
|
||||
*.sql text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
@@ -160,5 +160,9 @@ cython_debug/
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
.idea/
|
||||
|
||||
.vscode/
|
||||
.agents/
|
||||
.codex/
|
||||
.ruff_cache/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Источник: https://jtprog.ru/posts/docker-base/
|
||||
|
||||
# В качестве родителя используем slim-образ с Python 3.13
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Просим Python не писать .pyc файлы и не не буферизовать stdin/stdout
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Задаем рабочую директорию
|
||||
WORKDIR /srv/bitrix-deals-bot
|
||||
|
||||
# Создаем системную группу и пользователя для запуска приложения
|
||||
RUN groupadd --system runtime && useradd --system --gid runtime runtime
|
||||
|
||||
# Копируем файл зависимостей и устанавливаем их
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Копируем исходный код приложения в контейнер
|
||||
COPY --chown=runtime:runtime apps ./apps
|
||||
|
||||
# Переходим на созданного пользователя для запуска приложения
|
||||
USER runtime
|
||||
CMD ["python", "-m", "apps.bot"]
|
||||
@@ -1,26 +1,156 @@
|
||||
# BitrixDealsBot
|
||||
|
||||
BitrixDealsBot — это телеграм-бот, который позволяет пользователям
|
||||
**BitrixDealsBot** — это телеграм-бот, который позволяет пользователям
|
||||
взаимодействовать с CRM-системой Bitrix24 для управления сделками и контактами.
|
||||
Цель бота — упростить процесс работы с CRM, предоставляя удобный интерфейс для
|
||||
обновления и отслеживания сделок прямо из Telegram.
|
||||
|
||||
Задание выполняется в рамках учебной производственной практики для предприятия
|
||||
Интерволга.
|
||||
ООО "Интернет-агенство ИНТЕРВОЛГА".
|
||||
|
||||
## Формулировка задания
|
||||
|
||||
**Telegram-бот “Помощник менеджера CRM”**
|
||||
|
||||
Telegram-бот для менеджера по продажам. Бот помогает быстро смотреть новые лиды,
|
||||
Telegram-бот для менеджера по продажам. Бот помогает быстро смотреть новые лиды,
|
||||
брать их в работу, менять статус и добавлять комментарии.
|
||||
|
||||
**Стек:** Любой язык, любая БД, REST API Telegram, REST API Битрикс24
|
||||
|
||||
**Функции:**
|
||||
|
||||
- команда /leads показывает новые лиды (без ответственных);
|
||||
- команда /lead ### показывает карточку лида по указанному ID: имя, телефон, источник, статус;
|
||||
- команда /lead ### показывает карточку лида по указанному ID: имя, телефон,
|
||||
источник, статус;
|
||||
- кнопка “Взять” устанавливает ответственного;
|
||||
- кнопка “Позвонить позже” устанавливает ответственного и планирует звонок через 1 час;
|
||||
- кнопка “Позвонить позже” устанавливает ответственного и планирует звонок через
|
||||
1 час;
|
||||
- кнопка “Закрыть” возвращает в список лидов /leads;
|
||||
- команда /history показывает историю действий;
|
||||
- интеграция с Битрикс24 через webhook.
|
||||
- интеграция с Битрикс24 через webhook (Был осуществлен переход на OAuth).
|
||||
|
||||
## Архитектура решения
|
||||
|
||||
Основные архитектурные решения изложены в разделе ADR (Architecture Decision
|
||||
Records) в папке `docs/adr`. [Главный документ](docs/adr/main.md) может
|
||||
использоваться для навигации между заметками о принятых решениях.
|
||||
|
||||
## Настройка
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Для развертывания проекта требуется Docker и Docker Compose. В Windows
|
||||
> рекомендуется использовать WSL2. Также требуется внешний nginx для
|
||||
> проксирования запросов к сайту.
|
||||
|
||||
Клонируйте репозиторий и перейдите в папку проекта:
|
||||
|
||||
```bash
|
||||
cd BitrixDealsBot
|
||||
```
|
||||
|
||||
Скопируйте `.env.example` в `.env` и заполните значения. Ключ шифрования можно
|
||||
создать командой:
|
||||
|
||||
```bash
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
```
|
||||
|
||||
`TOKEN_ENCRYPTION_KEY` должен быть одинаковым у сайта и бота. В БД OAuth-токены
|
||||
попадают уже зашифрованными.
|
||||
|
||||
Сгенерируйте или иным способом придумайте пароли для ролей администратора,
|
||||
пользователя сайта и бота. В PostgreSQL роли создаются автоматически при первом
|
||||
создании контейнера `db`.
|
||||
|
||||
Также укажите URL, который будет использоваться в качестве базового адреса
|
||||
сайта. Соответственно, для этого бы желательно иметь свой домен, соответствующие
|
||||
DNS-записи и TLS-сертификат (например, от Let's Encrypt).
|
||||
|
||||
Создайте локальное приложение в Битрикс24 и укажите в нем URL для привязки:
|
||||
|
||||
```text
|
||||
https://bot.example.ru/bitrix/bind
|
||||
```
|
||||
|
||||
Битрикс сгенерирует `CLIENT_ID` и `CLIENT_SECRET`, которые нужно указать в
|
||||
`.env`. В настройках приложения разрешите доступ к CRM и к минимальной
|
||||
информации о пользователе.
|
||||
Битрикс передает сайту OAuth-данные приложения. Сайт обменивает `REFRESH_ID` на
|
||||
новую пару токенов и берет доверенные идентификаторы портала и пользователя из
|
||||
ответа OAuth-сервера. Затем он проверяет пользователя через `user.current`,
|
||||
сохраняет зашифрованную пару токенов и показывает ссылку на Telegram.
|
||||
|
||||
Также создайте бота в Telegram через BotFather и укажите его токен в `.env`.
|
||||
Дополнительно укажите в `.env` имя вашего бота, которое будет использоваться в
|
||||
ссылках на него.
|
||||
|
||||
Вы также можете изменить в '.env' стандартную стадию сделки, которая будет
|
||||
использоваться при взятии сделки в работу. По умолчанию это стадия "C1:PREPARATION".
|
||||
Если вы хотите использовать другую стадию, укажите ее код в
|
||||
переменной `BITRIX_TAKE_TO_WORK_STAGE_ID`.
|
||||
|
||||
## Запуск в Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
Создаются четыре контейнера:
|
||||
|
||||
- `db` — PostgreSQL без опубликованного порта;
|
||||
- `site` — Flask/Gunicorn на `127.0.0.1:8000` хостовой машины;
|
||||
- `bot` — aiogram polling без входящего порта;
|
||||
- `migrate` — контейнер для миграции БД, запускается при каждом
|
||||
`docker compose up` и завершается после выполнения миграций.
|
||||
|
||||
Инициализация БД выполняется автоматически только для нового volume.
|
||||
|
||||
### Внешний nginx
|
||||
|
||||
Nginx работает на хосте, вне Docker:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
Если на вашем сервере есть панели типа FastPanel, Plesk, ISPmanager, то вы
|
||||
можете создать сайт с обратным прокси прямо в интерфейсе панели.
|
||||
|
||||
## Транзакции и доступ к БД
|
||||
|
||||
Сайт подключается ролью `site_app`, бот — `bot_app`. Прямого доступа к таблицам
|
||||
у них нет.
|
||||
|
||||
- `binding.issue_v1` сохраняет OAuth-данные и выпускает ссылку одной
|
||||
транзакцией.
|
||||
- `binding.consume_v1` атомарно погашает ссылку и создает привязку.
|
||||
- `oauth.*` выдает и обновляет токены только боту.
|
||||
- короткая DB-аренда защищает refresh-токен от параллельного обновления.
|
||||
|
||||
Сетевые запросы к Битриксу не выполняются внутри транзакций.
|
||||
Назначения на сделки хранятся только в Битриксу. Локальная блокировка в процессе
|
||||
бота не дает двум Telegram-пользователям одновременно взять одну сделку, а
|
||||
повторная проверка `ASSIGNED_BY_ID` отсекает устаревшие кнопки.
|
||||
|
||||
## Команды бота
|
||||
|
||||
- `/start bind_<token>` — привязать пользователя.
|
||||
- `/deals` или `/leads` — показать сделки с фильтром и пагинацией.
|
||||
- `/deal 123` — открыть карточку сделки.
|
||||
- `/help` — показать справку.
|
||||
|
||||
Фильтр «Мои сделки» показывает сделки всех стадий, где ответственным назначен
|
||||
привязанный Битрикс-пользователь.
|
||||
|
||||
Кнопка «Позвонить позже» создает дело с напоминанием в Битриксе через час.
|
||||
История показывает пять последних переходов сделки по стадиям. Кнопка «Стать
|
||||
ответственным и взять в работу» использует ID привязанного Битрикс-пользователя.
|
||||
Ответственный также видит кнопку перехода на следующую стадию; финальный переход
|
||||
выделен как завершение сделки. Перед переходом бот запрашивает необязательный
|
||||
комментарий для таймлайна: пустая строка или прочерк означают переход без него.
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import asyncio
|
||||
import html
|
||||
import os
|
||||
import decimal
|
||||
|
||||
import httpx
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||
BITRIX_WEBHOOK_URL = os.getenv("BITRIX_WEBHOOK_URL")
|
||||
|
||||
dp = Dispatcher()
|
||||
|
||||
|
||||
async def bitrix_call(method: str, params: dict | None = None) -> dict:
|
||||
if not BITRIX_WEBHOOK_URL:
|
||||
raise RuntimeError("BITRIX_WEBHOOK_URL is not set")
|
||||
|
||||
base_url = BITRIX_WEBHOOK_URL.rstrip("/") + "/"
|
||||
url = base_url + method
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.post(url, json=params or {})
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if "error" in data:
|
||||
description = data.get("error_description", data["error"])
|
||||
raise RuntimeError(f"Bitrix API error: {description}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def format_deal(deal: dict) -> str:
|
||||
deal_id = html.escape(str(deal.get("ID", "—")))
|
||||
title = html.escape(str(deal.get("TITLE", "Без названия")))
|
||||
stage = html.escape(str(deal.get("STAGE_ID", "—")))
|
||||
opportunity = html.escape(str(deal.get("OPPORTUNITY", "—")))
|
||||
currency = html.escape(str(deal.get("CURRENCY_ID", "")))
|
||||
date = html.escape(str(deal.get("DATE_CREATE", "—")))
|
||||
|
||||
return (
|
||||
f"<b>#{deal_id} — {title}</b>\n"
|
||||
f"Стадия: <code>{stage}</code>\n"
|
||||
f"Сумма: {decimal.Decimal(opportunity):,.2f} {currency}\n"
|
||||
f"Дата создания: <code>{date}</code>"
|
||||
)
|
||||
|
||||
|
||||
@dp.message(Command("start"))
|
||||
async def start_handler(message: Message) -> None:
|
||||
await message.answer(
|
||||
"Привет. Команда /leads покажет последние сделки из Битрикс24."
|
||||
)
|
||||
|
||||
|
||||
@dp.message(Command("leads"))
|
||||
async def leads_handler(message: Message) -> None:
|
||||
await message.answer("Запрашиваю сделки...")
|
||||
|
||||
try:
|
||||
data = await bitrix_call(
|
||||
"crm.deal.list",
|
||||
{
|
||||
"order": {"DATE_CREATE": "DESC"},
|
||||
"filter": {},
|
||||
"select": [
|
||||
"ID",
|
||||
"TITLE",
|
||||
"STAGE_ID",
|
||||
"OPPORTUNITY",
|
||||
"CURRENCY_ID",
|
||||
"DATE_CREATE",
|
||||
],
|
||||
"start": 0,
|
||||
},
|
||||
)
|
||||
|
||||
deals = data.get("result", [])
|
||||
|
||||
if not deals:
|
||||
await message.answer("Сделки не найдены.")
|
||||
return
|
||||
|
||||
text = "\n\n".join(format_deal(deal) for deal in deals[:10])
|
||||
|
||||
await message.answer(
|
||||
f"<b>Последние сделки:</b>\n\n{text}",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
await message.answer(
|
||||
f"Ошибка HTTP при запросе к Битрикс24: {e.response.status_code}")
|
||||
|
||||
except httpx.RequestError:
|
||||
await message.answer("Не удалось подключиться к Битрикс24.")
|
||||
|
||||
except RuntimeError as e:
|
||||
await message.answer(f"Ошибка: {html.escape(str(e))}")
|
||||
|
||||
except Exception:
|
||||
await message.answer("Произошла неизвестная ошибка.")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
if not BOT_TOKEN:
|
||||
raise RuntimeError("BOT_TOKEN is not set")
|
||||
|
||||
bot = Bot(token=BOT_TOKEN)
|
||||
await dp.start_polling(bot)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import main
|
||||
|
||||
__all__ = ["main"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import main
|
||||
|
||||
main()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -0,0 +1,69 @@
|
||||
import atexit
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from flask import Flask, jsonify
|
||||
from dotenv import load_dotenv
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from .binding import BindingService, SiteBindingRepository
|
||||
from .bitrix import BitrixAuthError, BitrixClient
|
||||
from .config import SiteConfig
|
||||
from .crypto import TokenCipher
|
||||
from .database import SiteDatabase
|
||||
from .routes import SiteInputError, create_blueprint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
load_dotenv()
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
config = SiteConfig.from_env()
|
||||
app = Flask(__name__)
|
||||
app.config["PUBLIC_BASE_URL"] = config.public_base_url
|
||||
|
||||
if config.trust_proxy:
|
||||
# Используется конфигурация, при которой снаружи контейнера находится
|
||||
# обратный прокси nginx.
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=1,
|
||||
x_proto=1,
|
||||
x_host=1,
|
||||
)
|
||||
|
||||
database = SiteDatabase(config.database_url)
|
||||
database.open()
|
||||
bitrix = BitrixClient(
|
||||
config.bitrix_client_id,
|
||||
config.bitrix_client_secret,
|
||||
config.oauth_token_url,
|
||||
)
|
||||
bindings = BindingService(
|
||||
SiteBindingRepository(database),
|
||||
TokenCipher(config.token_encryption_key),
|
||||
config.bot_username,
|
||||
config.binding_ttl_seconds,
|
||||
)
|
||||
|
||||
app.extensions["database"] = database
|
||||
app.register_blueprint(create_blueprint(bindings, bitrix))
|
||||
atexit.register(database.close)
|
||||
atexit.register(bitrix.close)
|
||||
|
||||
@app.errorhandler(SiteInputError)
|
||||
def input_error(error: SiteInputError):
|
||||
return jsonify(error=str(error)), 400
|
||||
|
||||
@app.errorhandler(BitrixAuthError)
|
||||
def auth_error(error: BitrixAuthError):
|
||||
return jsonify(error=str(error)), 403
|
||||
|
||||
@app.errorhandler(httpx.HTTPError)
|
||||
def bitrix_error(error: httpx.HTTPError):
|
||||
logger.exception("Bitrix request failed")
|
||||
return jsonify(error="Не удалось проверить пользователя Bitrix24"), 502
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,98 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from .database import SiteDatabase
|
||||
from .crypto import TokenCipher
|
||||
|
||||
|
||||
def hash_token(token: str) -> bytes:
|
||||
return hashlib.sha256(token.encode("utf-8")).digest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BindingLink:
|
||||
url: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class SiteBindingRepository:
|
||||
"""Доступ сайта только к функциям выпуска токенов."""
|
||||
|
||||
def __init__(self, database: SiteDatabase) -> None:
|
||||
self.database = database
|
||||
|
||||
def issue(
|
||||
self,
|
||||
member_id: str,
|
||||
domain: str,
|
||||
bitrix_user_id: int,
|
||||
token_hash: bytes,
|
||||
token_expires_at: datetime,
|
||||
access_token: bytes,
|
||||
refresh_token: bytes,
|
||||
oauth_expires_at: datetime,
|
||||
) -> None:
|
||||
"""Сохраняет в БД информацию о токене, выданном пользователю."""
|
||||
query = """
|
||||
SELECT *
|
||||
FROM binding.issue_v1(%s, %s, %s, %s, %s, %s, %s, %s) \
|
||||
"""
|
||||
with self.database.transaction() as connection:
|
||||
connection.execute(
|
||||
query,
|
||||
(
|
||||
member_id,
|
||||
domain,
|
||||
bitrix_user_id,
|
||||
token_hash,
|
||||
token_expires_at,
|
||||
access_token,
|
||||
refresh_token,
|
||||
oauth_expires_at,
|
||||
),
|
||||
).fetchone()
|
||||
|
||||
|
||||
class BindingService:
|
||||
def __init__(
|
||||
self,
|
||||
repository: SiteBindingRepository,
|
||||
cipher: TokenCipher,
|
||||
bot_username: str,
|
||||
ttl_seconds: int,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.cipher = cipher
|
||||
self.bot_username = bot_username
|
||||
self.ttl_seconds = ttl_seconds
|
||||
|
||||
def issue(
|
||||
self,
|
||||
member_id: str,
|
||||
domain: str,
|
||||
bitrix_user_id: int,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
auth_expires_seconds: int,
|
||||
) -> BindingLink:
|
||||
"""Выдает ссылку для привязки аккаунта."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = datetime.now(UTC)
|
||||
token_expires_at = now + timedelta(seconds=self.ttl_seconds)
|
||||
oauth_expires_at = now + timedelta(seconds=auth_expires_seconds)
|
||||
self.repository.issue(
|
||||
member_id,
|
||||
domain,
|
||||
bitrix_user_id,
|
||||
hash_token(token),
|
||||
token_expires_at,
|
||||
self.cipher.encrypt(access_token),
|
||||
self.cipher.encrypt(refresh_token),
|
||||
oauth_expires_at,
|
||||
)
|
||||
return BindingLink(
|
||||
url=f"https://t.me/{self.bot_username}?start=bind_{token}",
|
||||
expires_at=token_expires_at,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class BitrixAuthError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BitrixUser:
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BitrixAuth:
|
||||
member_id: str
|
||||
domain: str
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
user: BitrixUser
|
||||
|
||||
|
||||
class BitrixClient:
|
||||
"""Получает доверенный OAuth-контекст и проверяет пользователя."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
oauth_token_url: str,
|
||||
client: httpx.Client | None = None,
|
||||
) -> None:
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.oauth_token_url = oauth_token_url
|
||||
# Клиент передается как внешняя зависимость для модульного тестирования.
|
||||
self._client = client or httpx.Client(timeout=15)
|
||||
# Соответственно, если клиент внешний, то класс
|
||||
# этим ресурсом не управляет.
|
||||
self._owns_client = client is None
|
||||
|
||||
def authorize(self, refresh_token: str) -> BitrixAuth:
|
||||
try:
|
||||
# Обмениваем рефреш-токен на новую пару токенов.
|
||||
# https://apidocs.bitrix24.com/settings/oauth/auto-renewal.html
|
||||
# https://apidocs.bitrix24.com/settings/oauth/simple-way.html
|
||||
response = self._client.get(
|
||||
self.oauth_token_url,
|
||||
params={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
# URL запроса содержит секреты, поэтому не пробрасываем его выше.
|
||||
raise BitrixAuthError("Не удалось обновить OAuth-токен") from None
|
||||
|
||||
data = response.json()
|
||||
if "error" in data:
|
||||
raise BitrixAuthError(
|
||||
str(data.get("error_description") or data["error"]))
|
||||
|
||||
# Получаем эндпоинт, с которым связаны наши токены.
|
||||
endpoint = str(data.get("client_endpoint") or "")
|
||||
parsed_endpoint = urlparse(endpoint)
|
||||
if parsed_endpoint.scheme != "https" or not parsed_endpoint.hostname:
|
||||
raise BitrixAuthError("Bitrix вернул некорректный REST endpoint")
|
||||
|
||||
# Сохраняем токен доступа и проверяем пользователя.
|
||||
access_token = str(data["access_token"])
|
||||
user = self._current_user(endpoint, access_token)
|
||||
expected_user_id = data.get("user_id")
|
||||
if expected_user_id is not None and user.id != int(expected_user_id):
|
||||
raise BitrixAuthError(
|
||||
"OAuth-токен принадлежит другому пользователю")
|
||||
|
||||
return BitrixAuth(
|
||||
member_id=str(data["member_id"]),
|
||||
domain=parsed_endpoint.hostname.lower(),
|
||||
access_token=access_token,
|
||||
refresh_token=str(data["refresh_token"]),
|
||||
expires_in=int(data.get("expires_in", 3600)),
|
||||
user=user,
|
||||
)
|
||||
|
||||
def _current_user(self, endpoint: str, access_token: str) -> BitrixUser:
|
||||
"""Получение информации о пользователе для проверки работоспособности."""
|
||||
# https://apidocs.bitrix24.com/api-reference/user/user-current.html
|
||||
response = self._client.post(
|
||||
endpoint.rstrip("/") + "/user.current.json",
|
||||
data={"auth": access_token},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if "error" in data or not data.get("result"):
|
||||
raise BitrixAuthError("Bitrix не подтвердил текущего пользователя")
|
||||
|
||||
user = data["result"]
|
||||
name = " ".join(
|
||||
part
|
||||
for part in (
|
||||
str(user.get("NAME") or "").strip(),
|
||||
str(user.get("LAST_NAME") or "").strip(),
|
||||
)
|
||||
if part
|
||||
)
|
||||
return BitrixUser(id=int(user["ID"]), name=name or f"ID {user['ID']}")
|
||||
|
||||
def close(self) -> None:
|
||||
if self._owns_client:
|
||||
self._client.close()
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _required(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise RuntimeError(f"{name} is not set")
|
||||
return value
|
||||
|
||||
|
||||
def _as_bool(value: str | None, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SiteConfig:
|
||||
"""Настройки HTTP-приложения."""
|
||||
|
||||
database_url: str
|
||||
public_base_url: str
|
||||
bot_username: str
|
||||
token_encryption_key: str
|
||||
bitrix_client_id: str
|
||||
bitrix_client_secret: str
|
||||
oauth_token_url: str
|
||||
binding_ttl_seconds: int = 600
|
||||
trust_proxy: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "SiteConfig":
|
||||
public_base_url = _required("PUBLIC_BASE_URL").rstrip("/")
|
||||
parsed_url = urlparse(public_base_url)
|
||||
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
||||
raise RuntimeError("PUBLIC_BASE_URL must be an absolute URL")
|
||||
|
||||
ttl = int(os.getenv("BINDING_TOKEN_TTL_SECONDS", "600"))
|
||||
if not 60 <= ttl <= 3600:
|
||||
raise RuntimeError("BINDING_TOKEN_TTL_SECONDS must be 60..3600")
|
||||
|
||||
return cls(
|
||||
database_url=_required("DATABASE_URL"),
|
||||
public_base_url=public_base_url,
|
||||
bot_username=_required("BOT_USERNAME").lstrip("@"),
|
||||
token_encryption_key=_required("TOKEN_ENCRYPTION_KEY"),
|
||||
bitrix_client_id=_required("BITRIX_CLIENT_ID"),
|
||||
bitrix_client_secret=_required("BITRIX_CLIENT_SECRET"),
|
||||
oauth_token_url=os.getenv(
|
||||
"BITRIX_OAUTH_TOKEN_URL",
|
||||
"https://oauth.bitrix.info/oauth/token/",
|
||||
),
|
||||
binding_ttl_seconds=ttl,
|
||||
trust_proxy=_as_bool(os.getenv("TRUST_PROXY"), default=True),
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
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"))
|
||||
@@ -0,0 +1,39 @@
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from psycopg import Connection
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
|
||||
class SiteDatabase:
|
||||
"""Данный класс представляет собой обертку над пулом соединений
|
||||
с базой данных PostgreSQL."""
|
||||
|
||||
def __init__(self, database_url: str) -> None:
|
||||
# Пул может содержать в себе максимум 5 соединений.
|
||||
self.pool = ConnectionPool(
|
||||
conninfo=database_url,
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
open=False,
|
||||
# Фабрика для представления строк БД как словарей.
|
||||
kwargs={"row_factory": dict_row},
|
||||
)
|
||||
|
||||
def open(self) -> None:
|
||||
self.pool.open(wait=True)
|
||||
|
||||
def close(self) -> None:
|
||||
self.pool.close()
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Generator[Connection]:
|
||||
with self.pool.connection() as connection:
|
||||
with connection.transaction():
|
||||
yield connection
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Простая проверка подключения к БД."""
|
||||
with self.pool.connection() as connection:
|
||||
return connection.execute("SELECT 1").fetchone() is not None
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
bind = f"{os.getenv('SITE_HOST', '0.0.0.0')}:{os.getenv('SITE_PORT', '8000')}"
|
||||
workers = int(os.getenv("SITE_WORKERS", "2"))
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
timeout = 30
|
||||
@@ -0,0 +1,67 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
|
||||
from .binding import BindingService
|
||||
from .bitrix import BitrixClient
|
||||
|
||||
|
||||
class SiteInputError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _field(payload: Mapping[str, Any], name: str) -> str:
|
||||
"""Проверка существования обязательного поля с именем name."""
|
||||
for key in (name, name.lower(), name.upper()):
|
||||
value = payload.get(key)
|
||||
if value is not None and str(value).strip():
|
||||
return str(value).strip()
|
||||
raise SiteInputError(f"Не передано поле {name}")
|
||||
|
||||
|
||||
def create_blueprint(
|
||||
bindings: BindingService,
|
||||
bitrix: BitrixClient,
|
||||
) -> Blueprint:
|
||||
blueprint = Blueprint("site", __name__)
|
||||
|
||||
@blueprint.get("/")
|
||||
def index():
|
||||
return jsonify(
|
||||
service="bitrix-telegram-binding",
|
||||
public_url=current_app.config["PUBLIC_BASE_URL"],
|
||||
)
|
||||
|
||||
@blueprint.get("/health")
|
||||
def health():
|
||||
database = current_app.extensions["database"]
|
||||
try:
|
||||
available = database.ping()
|
||||
except Exception:
|
||||
available = False
|
||||
status = "ok" if available else "error"
|
||||
return jsonify(status=status), 200 if available else 503
|
||||
|
||||
@blueprint.post("/bitrix/bind")
|
||||
def bind():
|
||||
payload = request.get_json(silent=True) or request.form
|
||||
refresh_token = _field(payload, "REFRESH_ID")
|
||||
|
||||
# OAuth-ответ дает доверенные ID портала и пользователя.
|
||||
auth = bitrix.authorize(refresh_token)
|
||||
link = bindings.issue(
|
||||
auth.member_id,
|
||||
auth.domain,
|
||||
auth.user.id,
|
||||
auth.access_token,
|
||||
auth.refresh_token,
|
||||
auth.expires_in,
|
||||
)
|
||||
return render_template(
|
||||
"binding.html",
|
||||
user=auth.user,
|
||||
link=link,
|
||||
)
|
||||
|
||||
return blueprint
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Привязка Telegram</title>
|
||||
<style>
|
||||
body {
|
||||
font: 16px sans-serif;
|
||||
max-width: 560px;
|
||||
margin: 48px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 12px 18px;
|
||||
color: white;
|
||||
background: #168acd;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Привязка Telegram</h1>
|
||||
<p>{{ user.name }}, откройте бота и подтвердите привязку.</p>
|
||||
<a href="{{ link.url }}" target="_blank" rel="noopener">Открыть Telegram</a>
|
||||
<small>Ссылка одноразовая и действует до {{ link.expires_at.strftime('%H:%M
|
||||
UTC') }}.</small>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
# Источник: https://jtprog.ru/posts/docker-base/
|
||||
|
||||
# Четыре моих основных сервиса: база данных, миграция, сайт и бот. Сайт и бот
|
||||
# используют одну базу данных, но от имени разных пользователей.
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-bitrix_bot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
SITE_DB_PASSWORD: ${SITE_DB_PASSWORD:?SITE_DB_PASSWORD is required}
|
||||
BOT_DB_PASSWORD: ${BOT_DB_PASSWORD:?BOT_DB_PASSWORD is required}
|
||||
volumes:
|
||||
# Данные базы данных будут храниться в volume, чтобы при
|
||||
# пересоздании контейнера данные не терялись.
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
# Скрипты инициализации базы данных будут монтироваться в контейнер (только для чтения),
|
||||
# чтобы при пересоздании контейнера они не терялись.
|
||||
- ./db/init:/docker-entrypoint-initdb.d:ro
|
||||
# Проверка готовности базы данных.
|
||||
# Контейнеры сайта и бота будут ждать, пока база данных не станет доступной.
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-bitrix_bot}" ]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# Сервис для выполнения миграций базы данных.
|
||||
migrate:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGPORT: 5432
|
||||
PGUSER: ${POSTGRES_USER:-postgres}
|
||||
PGDATABASE: ${POSTGRES_DB:-bitrix_bot}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
command: [ "sh", "/scripts/migrate.sh" ]
|
||||
volumes:
|
||||
- ./db/migrations:/migrations:ro
|
||||
- ./db/migrate.sh:/scripts/migrate.sh:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
site:
|
||||
# Сборка отдельного образа для сайта из Dockerfile в корне проекта.
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
# Gunicorn будет запускать приложение Flask.
|
||||
command:
|
||||
- gunicorn
|
||||
- --config
|
||||
- apps/site/gunicorn.conf.py
|
||||
- apps.site:create_app()
|
||||
environment:
|
||||
DATABASE_URL: postgresql://site_app:${SITE_DB_PASSWORD}@db:5432/${POSTGRES_DB:-bitrix_bot}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||
BOT_USERNAME: ${BOT_USERNAME}
|
||||
BITRIX_CLIENT_ID: ${BITRIX_CLIENT_ID}
|
||||
BITRIX_CLIENT_SECRET: ${BITRIX_CLIENT_SECRET}
|
||||
BITRIX_OAUTH_TOKEN_URL: ${BITRIX_OAUTH_TOKEN_URL:-https://oauth.bitrix.info/oauth/token/}
|
||||
TOKEN_ENCRYPTION_KEY: ${TOKEN_ENCRYPTION_KEY}
|
||||
BINDING_TOKEN_TTL_SECONDS: ${BINDING_TOKEN_TTL_SECONDS:-600}
|
||||
TRUST_PROXY: "true"
|
||||
SITE_HOST: 0.0.0.0
|
||||
SITE_PORT: ${SITE_PORT:-8000}
|
||||
# Количество воркеров Gunicorn.
|
||||
SITE_WORKERS: ${SITE_WORKERS:-2}
|
||||
ports:
|
||||
# Публикуем порт сайта на хост-машине.
|
||||
- "127.0.0.1:${SITE_PUBLISHED_PORT:-8000}:${SITE_PORT:-8000}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
bot:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
command: [ "python", "-m", "apps.bot" ]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://bot_app:${BOT_DB_PASSWORD}@db:5432/${POSTGRES_DB:-bitrix_bot}
|
||||
BOT_TOKEN: ${BOT_TOKEN}
|
||||
BITRIX_CLIENT_ID: ${BITRIX_CLIENT_ID}
|
||||
BITRIX_CLIENT_SECRET: ${BITRIX_CLIENT_SECRET}
|
||||
BITRIX_OAUTH_TOKEN_URL: ${BITRIX_OAUTH_TOKEN_URL:-https://oauth.bitrix.info/oauth/token/}
|
||||
TOKEN_ENCRYPTION_KEY: ${TOKEN_ENCRYPTION_KEY}
|
||||
BITRIX_TAKE_TO_WORK_STAGE_ID: ${BITRIX_TAKE_TO_WORK_STAGE_ID:-PREPARATION}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# Именованный volume для хранения базы данных.
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${SITE_DB_PASSWORD:?SITE_DB_PASSWORD is required}"
|
||||
: "${BOT_DB_PASSWORD:?BOT_DB_PASSWORD is required}"
|
||||
|
||||
psql -v ON_ERROR_STOP=1 \
|
||||
--username "$POSTGRES_USER" \
|
||||
--dbname "$POSTGRES_DB" \
|
||||
--set=site_password="$SITE_DB_PASSWORD" \
|
||||
--set=bot_password="$BOT_DB_PASSWORD" <<'SQL'
|
||||
DO $roles$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'site_role') THEN
|
||||
CREATE ROLE site_role NOLOGIN;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bot_role') THEN
|
||||
CREATE ROLE bot_role NOLOGIN;
|
||||
END IF;
|
||||
END
|
||||
$roles$;
|
||||
|
||||
CREATE ROLE site_app LOGIN PASSWORD :'site_password' IN ROLE site_role;
|
||||
CREATE ROLE bot_app LOGIN PASSWORD :'bot_password' IN ROLE bot_role;
|
||||
SQL
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PSQL="psql --no-psqlrc --set=ON_ERROR_STOP=1"
|
||||
|
||||
echo "Проверка таблицы миграций"
|
||||
|
||||
$PSQL <<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS public.schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
checksum text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
REVOKE ALL ON public.schema_migrations FROM PUBLIC;
|
||||
SQL
|
||||
|
||||
find /migrations \
|
||||
-maxdepth 1 \
|
||||
-type f \
|
||||
-name '[0-9][0-9][0-9]_*.sql' |
|
||||
sort |
|
||||
while IFS= read -r file; do
|
||||
version="$(basename "$file")"
|
||||
checksum="$(sha256sum "$file" | cut -d ' ' -f 1)"
|
||||
|
||||
saved_checksum="$(
|
||||
$PSQL \
|
||||
--tuples-only \
|
||||
--no-align \
|
||||
--set=migration_version="$version" <<'SQL'
|
||||
SELECT checksum
|
||||
FROM public.schema_migrations
|
||||
WHERE version = :'migration_version';
|
||||
SQL
|
||||
)"
|
||||
|
||||
if [ -n "$saved_checksum" ]; then
|
||||
if [ "$saved_checksum" != "$checksum" ]; then
|
||||
echo "Ошибка: применённая миграция $version была изменена"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Пропуск $version"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Применение $version"
|
||||
|
||||
{
|
||||
echo "BEGIN;"
|
||||
cat "$file"
|
||||
echo ""
|
||||
echo "INSERT INTO public.schema_migrations(version, checksum)"
|
||||
echo "VALUES (:'migration_version', :'migration_checksum');"
|
||||
echo "COMMIT;"
|
||||
} |
|
||||
$PSQL \
|
||||
--set=migration_version="$version" \
|
||||
--set=migration_checksum="$checksum"
|
||||
|
||||
echo "Миграция $version применена"
|
||||
done
|
||||
|
||||
echo "Все миграции применены"
|
||||
@@ -0,0 +1,493 @@
|
||||
-- Подключение расширения pgcrypto для генерации UUID.
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE SCHEMA IF NOT EXISTS binding;
|
||||
CREATE SCHEMA IF NOT EXISTS oauth;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS binding.portals (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
-- member_id - уникальный идентификатор портала из Битрикса.
|
||||
member_id text NOT NULL UNIQUE,
|
||||
-- domain - домен портала, например: example.bitrix24.ru.
|
||||
domain text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS binding.tokens (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
|
||||
bitrix_user_id bigint NOT NULL,
|
||||
-- Хеш токена, генерируется сервером.
|
||||
token_hash bytea NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS binding_tokens_owner_idx
|
||||
ON binding.tokens (portal_id, bitrix_user_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS binding.user_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
|
||||
bitrix_user_id bigint NOT NULL,
|
||||
telegram_user_id bigint NOT NULL,
|
||||
telegram_chat_id bigint NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (portal_id, bitrix_user_id),
|
||||
UNIQUE (portal_id, telegram_user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth.user_credentials (
|
||||
portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
|
||||
bitrix_user_id bigint NOT NULL,
|
||||
access_token bytea NOT NULL,
|
||||
refresh_token bytea NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
-- Двойной механизм защиты от гонок данных при обновлении токенов.
|
||||
version bigint NOT NULL DEFAULT 1,
|
||||
refresh_locked_until timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (portal_id, bitrix_user_id)
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION binding.issue_v1(
|
||||
p_member_id text,
|
||||
p_domain text,
|
||||
p_bitrix_user_id bigint,
|
||||
p_token_hash bytea,
|
||||
p_token_expires_at timestamptz,
|
||||
p_access_token bytea,
|
||||
p_refresh_token bytea,
|
||||
p_oauth_expires_at timestamptz
|
||||
)
|
||||
RETURNS TABLE(token_id uuid, expires_at timestamptz)
|
||||
LANGUAGE plpgsql
|
||||
-- Задаем SECURITY DEFINER, чтобы функция выполнялась с правами владельца
|
||||
-- схемы binding.
|
||||
SECURITY DEFINER
|
||||
-- Устанавливаем search_path в pg_catalog, чтобы нельзя было подменить
|
||||
-- функции в схеме binding или oauth.
|
||||
SET search_path = pg_catalog
|
||||
AS $function$
|
||||
#variable_conflict error
|
||||
DECLARE
|
||||
v_portal_id bigint;
|
||||
v_token_id uuid;
|
||||
BEGIN
|
||||
-- Добавляем данные о портале, если его ещё нет,
|
||||
-- или обновляем домен, если портал уже существует.
|
||||
INSERT INTO binding.portals(member_id, domain)
|
||||
VALUES (p_member_id, lower(p_domain))
|
||||
ON CONFLICT (member_id) DO UPDATE
|
||||
SET domain = EXCLUDED.domain,
|
||||
updated_at = now()
|
||||
RETURNING id INTO v_portal_id;
|
||||
|
||||
-- Сохраняем OAuth-данные пользователя, если их ещё нет,
|
||||
-- или обновляем их, если они уже существуют.
|
||||
INSERT INTO oauth.user_credentials(
|
||||
portal_id,
|
||||
bitrix_user_id,
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at
|
||||
)
|
||||
VALUES (
|
||||
v_portal_id,
|
||||
p_bitrix_user_id,
|
||||
p_access_token,
|
||||
p_refresh_token,
|
||||
p_oauth_expires_at
|
||||
)
|
||||
ON CONFLICT (portal_id, bitrix_user_id) DO UPDATE
|
||||
SET access_token = EXCLUDED.access_token,
|
||||
refresh_token = EXCLUDED.refresh_token,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
version = oauth.user_credentials.version + 1,
|
||||
refresh_locked_until = NULL,
|
||||
updated_at = now();
|
||||
|
||||
-- Отзываем все предыдущие неиспользованные токены пользователя.
|
||||
UPDATE binding.tokens
|
||||
SET revoked_at = now()
|
||||
WHERE portal_id = v_portal_id
|
||||
AND bitrix_user_id = p_bitrix_user_id
|
||||
AND consumed_at IS NULL
|
||||
AND revoked_at IS NULL;
|
||||
|
||||
-- Выпускаем новый токен привязки.
|
||||
INSERT INTO binding.tokens(
|
||||
portal_id,
|
||||
bitrix_user_id,
|
||||
token_hash,
|
||||
expires_at
|
||||
)
|
||||
VALUES (
|
||||
v_portal_id,
|
||||
p_bitrix_user_id,
|
||||
p_token_hash,
|
||||
p_token_expires_at
|
||||
)
|
||||
RETURNING id INTO v_token_id;
|
||||
|
||||
-- Возвращаем идентификатор токена и срок его действия.
|
||||
RETURN QUERY SELECT v_token_id, p_token_expires_at;
|
||||
END
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION binding.issue_v1(
|
||||
text,
|
||||
text,
|
||||
bigint,
|
||||
bytea,
|
||||
timestamptz,
|
||||
bytea,
|
||||
bytea,
|
||||
timestamptz
|
||||
)
|
||||
IS $doc$
|
||||
Сохраняет OAuth-данные пользователя и выпускает токен привязки.
|
||||
|
||||
Гарантии:
|
||||
- предыдущие неиспользованные токены отзываются;
|
||||
- при обновлении OAuth-данных увеличивается их версия.
|
||||
|
||||
Возвращает:
|
||||
- token_id - идентификатор токена;
|
||||
- expires_at - срок действия токена.
|
||||
$doc$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION binding.consume_v1(
|
||||
p_token_hash bytea,
|
||||
p_telegram_user_id bigint,
|
||||
p_telegram_chat_id bigint
|
||||
)
|
||||
RETURNS TABLE(
|
||||
member_id text,
|
||||
domain text,
|
||||
bitrix_user_id bigint,
|
||||
telegram_user_id bigint
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog
|
||||
AS $function$
|
||||
#variable_conflict error
|
||||
DECLARE
|
||||
v_portal_id bigint;
|
||||
v_bitrix_user_id bigint;
|
||||
BEGIN
|
||||
-- UPDATE не позволит двум запросам погасить один токен.
|
||||
UPDATE binding.tokens AS token
|
||||
SET consumed_at = now()
|
||||
WHERE token.token_hash = p_token_hash
|
||||
AND token.consumed_at IS NULL
|
||||
AND token.revoked_at IS NULL
|
||||
AND token.expires_at > now()
|
||||
RETURNING token.portal_id, token.bitrix_user_id
|
||||
INTO v_portal_id, v_bitrix_user_id;
|
||||
|
||||
-- Если UPDATE не вернул ни одной строки, значит токен недействителен.
|
||||
IF NOT FOUND THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Удаляем все привязки к Telegram для данного портала и пользователя Битрикса,
|
||||
-- кроме той, которая соответствует текущему пользователю Битрикса.
|
||||
DELETE FROM binding.user_bindings AS user_binding
|
||||
WHERE user_binding.portal_id = v_portal_id
|
||||
AND user_binding.telegram_user_id = p_telegram_user_id
|
||||
AND user_binding.bitrix_user_id <> v_bitrix_user_id;
|
||||
|
||||
-- Добавляем или обновляем привязку к Telegram для данного портала
|
||||
-- и пользователя Битрикса.
|
||||
INSERT INTO binding.user_bindings(
|
||||
portal_id,
|
||||
bitrix_user_id,
|
||||
telegram_user_id,
|
||||
telegram_chat_id
|
||||
)
|
||||
VALUES (
|
||||
v_portal_id,
|
||||
v_bitrix_user_id,
|
||||
p_telegram_user_id,
|
||||
p_telegram_chat_id
|
||||
)
|
||||
ON CONFLICT ON CONSTRAINT user_bindings_pkey DO UPDATE
|
||||
SET telegram_user_id = EXCLUDED.telegram_user_id,
|
||||
telegram_chat_id = EXCLUDED.telegram_chat_id,
|
||||
updated_at = now();
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
portal.member_id,
|
||||
portal.domain,
|
||||
v_bitrix_user_id,
|
||||
p_telegram_user_id
|
||||
FROM binding.portals AS portal
|
||||
WHERE portal.id = v_portal_id;
|
||||
END
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION binding.consume_v1(
|
||||
bytea,
|
||||
bigint,
|
||||
bigint
|
||||
)
|
||||
IS $doc$
|
||||
Погашает токен привязки и сохраняет привязку к Telegram.
|
||||
|
||||
Гарантии:
|
||||
- токен погашается только один раз;
|
||||
- если токен недействителен, функция возвращает пустой результат;
|
||||
- если токен действителен, функция возвращает данные портала и пользователя
|
||||
Битрикса, а также сохраняет привязку к Telegram;
|
||||
- если пользователь Битрикса уже был привязан к другому пользователю Telegram,
|
||||
старая привязка удаляется.
|
||||
|
||||
Возвращает:
|
||||
- member_id - идентификатор портала;
|
||||
- domain - домен портала;
|
||||
- bitrix_user_id - идентификатор пользователя Битрикса;
|
||||
- telegram_user_id - идентификатор пользователя Telegram.
|
||||
$doc$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION binding.find_by_telegram_v1(
|
||||
p_telegram_user_id bigint,
|
||||
p_member_id text DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE(
|
||||
member_id text,
|
||||
domain text,
|
||||
bitrix_user_id bigint,
|
||||
telegram_user_id bigint
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog
|
||||
AS $function$
|
||||
SELECT
|
||||
portal.member_id,
|
||||
portal.domain,
|
||||
user_binding.bitrix_user_id,
|
||||
user_binding.telegram_user_id
|
||||
FROM binding.user_bindings AS user_binding
|
||||
JOIN binding.portals AS portal ON portal.id = user_binding.portal_id
|
||||
WHERE user_binding.telegram_user_id = p_telegram_user_id
|
||||
AND (p_member_id IS NULL OR portal.member_id = p_member_id)
|
||||
ORDER BY user_binding.updated_at DESC
|
||||
LIMIT 1
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION binding.find_by_telegram_v1(
|
||||
bigint,
|
||||
text
|
||||
)
|
||||
IS $doc$
|
||||
Находит привязку к Telegram по идентификатору пользователя Telegram.
|
||||
|
||||
Возвращает:
|
||||
- member_id - идентификатор портала;
|
||||
- domain - домен портала;
|
||||
- bitrix_user_id - идентификатор пользователя Битрикса;
|
||||
- telegram_user_id - идентификатор пользователя Telegram.
|
||||
Если p_member_id не NULL, то поиск ограничивается указанным порталом.
|
||||
$doc$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION oauth.get_credentials_v1(
|
||||
p_member_id text,
|
||||
p_bitrix_user_id bigint
|
||||
)
|
||||
RETURNS TABLE(
|
||||
member_id text,
|
||||
domain text,
|
||||
bitrix_user_id bigint,
|
||||
access_token bytea,
|
||||
refresh_token bytea,
|
||||
expires_at timestamptz,
|
||||
version bigint
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog
|
||||
AS $function$
|
||||
SELECT
|
||||
portal.member_id,
|
||||
portal.domain,
|
||||
credentials.bitrix_user_id,
|
||||
credentials.access_token,
|
||||
credentials.refresh_token,
|
||||
credentials.expires_at,
|
||||
credentials.version
|
||||
FROM oauth.user_credentials AS credentials
|
||||
JOIN binding.portals AS portal ON portal.id = credentials.portal_id
|
||||
WHERE portal.member_id = p_member_id
|
||||
AND credentials.bitrix_user_id = p_bitrix_user_id
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION oauth.get_credentials_v1(
|
||||
text,
|
||||
bigint
|
||||
)
|
||||
IS $doc$
|
||||
Находит OAuth-данные пользователя по идентификатору портала и идентификатору
|
||||
пользователя Битрикса.
|
||||
|
||||
Возвращает:
|
||||
- member_id - идентификатор портала;
|
||||
- domain - домен портала;
|
||||
- bitrix_user_id - идентификатор пользователя Битрикса;
|
||||
- access_token - токен доступа;
|
||||
- refresh_token - токен обновления;
|
||||
- expires_at - срок действия токена доступа;
|
||||
- version - версия данных.
|
||||
$doc$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION oauth.claim_refresh_v1(
|
||||
p_member_id text,
|
||||
p_bitrix_user_id bigint,
|
||||
p_version bigint
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
VOLATILE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog
|
||||
AS $function$
|
||||
-- Создаем временную таблицу claimed,
|
||||
-- которая будет содержать результат обновления (CTE).
|
||||
WITH claimed AS (
|
||||
UPDATE oauth.user_credentials AS credentials
|
||||
SET refresh_locked_until = now() + interval '30 seconds'
|
||||
FROM binding.portals AS portal
|
||||
WHERE credentials.portal_id = portal.id
|
||||
AND portal.member_id = p_member_id
|
||||
AND credentials.bitrix_user_id = p_bitrix_user_id
|
||||
AND credentials.version = p_version
|
||||
AND (
|
||||
credentials.refresh_locked_until IS NULL
|
||||
OR credentials.refresh_locked_until < now()
|
||||
)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT EXISTS(SELECT 1 FROM claimed)
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION oauth.claim_refresh_v1(
|
||||
text,
|
||||
bigint,
|
||||
bigint
|
||||
)
|
||||
IS $doc$
|
||||
Пытается захватить токен обновления для пользователя.
|
||||
|
||||
Возвращает:
|
||||
- true, если захват успешен;
|
||||
- false, если захват не удался.
|
||||
$doc$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION oauth.finish_refresh_v1(
|
||||
p_member_id text,
|
||||
p_bitrix_user_id bigint,
|
||||
p_version bigint,
|
||||
p_access_token bytea,
|
||||
p_refresh_token bytea,
|
||||
p_expires_at timestamptz
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
VOLATILE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog
|
||||
AS $function$
|
||||
WITH updated AS (
|
||||
UPDATE oauth.user_credentials AS credentials
|
||||
SET access_token = p_access_token,
|
||||
refresh_token = p_refresh_token,
|
||||
expires_at = p_expires_at,
|
||||
version = credentials.version + 1,
|
||||
refresh_locked_until = NULL,
|
||||
updated_at = now()
|
||||
FROM binding.portals AS portal
|
||||
WHERE credentials.portal_id = portal.id
|
||||
AND portal.member_id = p_member_id
|
||||
AND credentials.bitrix_user_id = p_bitrix_user_id
|
||||
AND credentials.version = p_version
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT EXISTS(SELECT 1 FROM updated)
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION oauth.finish_refresh_v1(
|
||||
text,
|
||||
bigint,
|
||||
bigint,
|
||||
bytea,
|
||||
bytea,
|
||||
timestamptz
|
||||
) IS $doc$
|
||||
Завершает процесс обновления токена для пользователя.
|
||||
|
||||
Возвращает:
|
||||
- true, если обновление успешно завершено;
|
||||
- false, если обновление не удалось.
|
||||
$doc$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION oauth.release_refresh_v1(
|
||||
p_member_id text,
|
||||
p_bitrix_user_id bigint,
|
||||
p_version bigint
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE sql
|
||||
VOLATILE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog
|
||||
AS $function$
|
||||
UPDATE oauth.user_credentials AS credentials
|
||||
SET refresh_locked_until = NULL
|
||||
FROM binding.portals AS portal
|
||||
WHERE credentials.portal_id = portal.id
|
||||
AND portal.member_id = p_member_id
|
||||
AND credentials.bitrix_user_id = p_bitrix_user_id
|
||||
AND credentials.version = p_version
|
||||
$function$;
|
||||
|
||||
COMMENT ON FUNCTION oauth.release_refresh_v1(
|
||||
text,
|
||||
bigint,
|
||||
bigint
|
||||
) IS $doc$
|
||||
Освобождает токен обновления для пользователя.
|
||||
|
||||
Возвращает:
|
||||
- void.
|
||||
$doc$;
|
||||
|
||||
-- Отзываем все права у PUBLIC.
|
||||
REVOKE ALL ON ALL TABLES IN SCHEMA binding, oauth FROM PUBLIC;
|
||||
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA binding, oauth FROM PUBLIC;
|
||||
|
||||
-- Даем права на использование схемы и выполнение функций ролям site_role
|
||||
-- и bot_role.
|
||||
GRANT USAGE ON SCHEMA binding TO site_role, bot_role;
|
||||
GRANT USAGE ON SCHEMA oauth TO bot_role;
|
||||
|
||||
-- Даем права на выполнение функций ролям site_role и bot_role.
|
||||
GRANT EXECUTE ON FUNCTION binding.issue_v1(
|
||||
text, text, bigint, bytea, timestamptz, bytea, bytea, timestamptz
|
||||
) TO site_role;
|
||||
GRANT EXECUTE ON FUNCTION binding.consume_v1(bytea, bigint, bigint) TO bot_role;
|
||||
GRANT EXECUTE ON FUNCTION binding.find_by_telegram_v1(bigint, text) TO bot_role;
|
||||
GRANT EXECUTE ON FUNCTION oauth.get_credentials_v1(text, bigint) TO bot_role;
|
||||
GRANT EXECUTE ON FUNCTION oauth.claim_refresh_v1(text, bigint, bigint) TO bot_role;
|
||||
GRANT EXECUTE ON FUNCTION oauth.finish_refresh_v1(
|
||||
text, bigint, bigint, bytea, bytea, timestamptz
|
||||
) TO bot_role;
|
||||
GRANT EXECUTE ON FUNCTION oauth.release_refresh_v1(text, bigint, bigint)
|
||||
TO bot_role;
|
||||
@@ -0,0 +1,64 @@
|
||||
# ADR-001: разделение приложения на сайт, Telegram-бот и базу данных
|
||||
|
||||
**Статус:** Принято
|
||||
**Дата:** 2026-07-23
|
||||
|
||||
## Контекст
|
||||
|
||||
Приложение включает страницу привязки пользователя Битрикс24, Telegram-интерфейс
|
||||
менеджера и хранилище интеграционных данных. HTTP-сайт обрабатывает короткие
|
||||
входящие запросы, тогда как Telegram-бот выполняет длительный polling и
|
||||
параллельные REST-операции.
|
||||
|
||||
## Решение
|
||||
|
||||
Архитектура разделена на три основные структурные единицы: сайт привязки,
|
||||
Telegram-бот и база данных, которая обслуживает остальные компоненты. Зона
|
||||
ответственности каждого модуля определена отдельно. Сайт и бот не делят общий
|
||||
код, а их права на уровне базы данных ограничены.
|
||||
|
||||
HTTP-приложение `site` обслуживает только инициацию привязки и взаимодействует с
|
||||
REST API и OAuth Битрикс24. Telegram-приложение `bot` обрабатывает команды
|
||||
менеджера и взаимодействует как с REST API и OAuth Битрикс24, так и с Telegram
|
||||
Bot API. PostgreSQL предоставляет обоим процессам устойчивый версионированный
|
||||
контракт в виде `SECURITY DEFINER`-функций и поддерживает применение миграций.
|
||||
|
||||
Программное решение регистрируется администратором портала как локальное
|
||||
приложение с указанием ссылки на страницу привязки. Локальное приложение
|
||||
отправляет на HTTPS-адрес `/bitrix/bind` идентификационные данные пользователя и
|
||||
refresh-токен. Сайт обменивает его на новую пару OAuth-токенов, извлекает
|
||||
доверенные `member_id`, `user_id` и `client_endpoint` из ответа Битрикс24,
|
||||
проверяет пользователя методом `user.current` и только после этого выпускает
|
||||
одноразовую ссылку Telegram.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-001/1. UML-диаграмма компонентов программного решения*
|
||||
|
||||
На схеме также обозначен сервис `migrate`. Он не является постоянно запущенным
|
||||
модулем, но отвечает за миграции схемы БД. При перезапуске Docker Compose сервис
|
||||
последовательно выполняет необходимые SQL-скрипты. Сайт имеет право выполнять
|
||||
только функцию выпуска ссылки `binding.issue_v1`. Бот погашает ссылку, получает
|
||||
привязку и обращается к OAuth-функциям. Прямые операции `SELECT`, `INSERT` и
|
||||
`UPDATE` над таблицами для ролей приложений запрещены, поэтому граница базы
|
||||
данных одновременно является границей доступа.
|
||||
|
||||
| Компонент | Ответственность | Внешний интерфейс |
|
||||
|------------|----------------------------------------------------------|------------------------------------------|
|
||||
| nginx | Завершение TLS и проксирование только к сайту | HTTPS → 127.0.0.1:8000 |
|
||||
| apps.site | OAuth-проверка пользователя и выпуск одноразовой ссылки | POST /bitrix/bind, GET /health |
|
||||
| apps.bot | Команды Telegram, карточки и операции со сделками | Telegram Bot API, Битрикс REST |
|
||||
| PostgreSQL | Привязки, токены, транзакционная синхронизация | binding.\* и oauth.\* |
|
||||
| migrate | Однократное применение SQL-миграций до старта приложений | db/migrations/\*.sql |
|
||||
| Битрикс24 | Источник CRM-данных и OAuth-контекста | oauth/token, user.current, crm.\* |
|
||||
| Telegram | Пользовательский канал и доставка callback-событий | getUpdates, sendMessage, editMessageText |
|
||||
|
||||
*Таблица ADR-001/1. Ответственность компонентов архитектуры*
|
||||
|
||||
## Последствия
|
||||
|
||||
Разделение уменьшает связанность и позволяет перезапускать или масштабировать
|
||||
процессы независимо. Для коротких входящих запросов используется синхронный
|
||||
Flask/Gunicorn, а для длительного polling и параллельных REST-операций —
|
||||
asyncio/aiogram. Связь приложений формализована версионированными функциями
|
||||
PostgreSQL вместо общего программного модуля.
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-002: привязка пользователей Битрикс24 и Telegram
|
||||
|
||||
**Статус:** Принято
|
||||
**Дата:** 2026-07-23
|
||||
|
||||
## Контекст
|
||||
|
||||
Локальное приложение CRM отправляет на HTTPS-адрес `/bitrix/bind`
|
||||
идентификационные данные пользователя и refresh-токен. Идентификаторам портала и
|
||||
пользователя из входной формы доверять нельзя: контекст должен быть получен от
|
||||
OAuth-сервера Битрикс24 и подтверждён методом `user.current`.
|
||||
|
||||
## Решение
|
||||
|
||||
Сайт использует refresh-токен для получения новой OAuth-пары, доверенных
|
||||
`member_id`, `user_id` и `client_endpoint`. После этого `BitrixClient` сверяет
|
||||
`user_id` с результатом `user.current`.
|
||||
|
||||
Процесс привязки учётных записей представлен на диаграмме последовательности.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-002/1. Диаграмма последовательности привязки Битрикс24 к Telegram*
|
||||
|
||||
После проверки пользователя функцией `secrets.token_urlsafe(32)` формируется
|
||||
одноразовый токен привязки. В БД записывается только SHA-256-хеш, поэтому
|
||||
компрометация базы не позволяет восстановить действующую ссылку. Срок жизни
|
||||
задаётся переменной окружения `BINDING_TOKEN_TTL_SECONDS`, ограничен диапазоном
|
||||
от 60 до 3600 секунд и по умолчанию равен 600 секундам. При повторном выпуске
|
||||
прежние непогашенные токены того же пользователя отзываются.
|
||||
|
||||
Пользователь переходит по одноразовой ссылке в чат с Telegram-ботом. Бот
|
||||
повторно вычисляет SHA-256-хеш и сверяет его с активными токенами. Если токен
|
||||
существует, не истёк, не отозван и ещё не погашен, он помечается использованным,
|
||||
а в таблице привязок создаётся или обновляется связь пользователя Битрикс24 с
|
||||
аккаунтом Telegram.
|
||||
|
||||
Погашение выполняется только в личном чате. Проверка токена и изменение привязки
|
||||
выполняются функцией `binding.consume_v1` в одной транзакции.
|
||||
|
||||
## Последствия
|
||||
|
||||
Привязка не использует идентификаторы пользователя из недоверенной входной
|
||||
формы. В базе хранится только хеш одноразового токена, а повторный выпуск ссылки
|
||||
отзывает предыдущие непогашенные токены. Атомарное погашение не позволяет двум
|
||||
запросам одновременно использовать одну ссылку.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ADR-003: защита и обновление OAuth-токенов
|
||||
|
||||
**Статус:** Принято
|
||||
**Дата:** 2026-07-23
|
||||
|
||||
## Контекст
|
||||
|
||||
Для выполнения REST-запросов приложение хранит `access_token` и `refresh_token`.
|
||||
Битрикс24 возвращает новую пару токенов при каждом обновлении, поэтому
|
||||
одновременное использование одного refresh-токена несколькими воркерами может
|
||||
привести к потере актуальной пары.
|
||||
|
||||
## Решение
|
||||
|
||||
До передачи в PostgreSQL `access_token` и `refresh_token` шифруются алгоритмом
|
||||
Fernet. Общий `TOKEN_ENCRYPTION_KEY` передаётся контейнерам `site` и `bot` через
|
||||
переменные окружения, но не записывается в базу. Бот расшифровывает access-токен
|
||||
непосредственно перед REST-запросом и не включает OAuth-параметры в тексты
|
||||
ошибок.
|
||||
|
||||
Принятые меры защиты сведены в таблицу.
|
||||
|
||||
| Риск | Реализованная мера | Остаточный контроль |
|
||||
|---------------------------------|--------------------------------------------|--------------------------------------|
|
||||
| Утечка одноразовой ссылки из БД | Хранение SHA-256-хеша | Короткий TTL и однократное погашение |
|
||||
| Чтение OAuth-токенов из БД | Fernet-шифрование до INSERT/UPDATE | Секретный ключ вне БД |
|
||||
| Подмена пользователя | member_id/user_id из OAuth + user.current | Проверка HTTPS endpoint |
|
||||
| Гонка refresh token | Версия и аренда refresh_locked_until | Повторное чтение до версии N+1 |
|
||||
| Избыточные права приложений | Разные роли и EXECUTE только на функции | REVOKE для PUBLIC |
|
||||
| Долгая транзакция | Сетевые запросы выполняются вне транзакции | Короткие контексты Psycopg |
|
||||
|
||||
*Таблица ADR-003/1. Риски и меры защиты от них*
|
||||
|
||||
При получении `expired_token`, `invalid_token` или `no_auth_found` клиент
|
||||
пытается обновить пару токенов. Поле `version` реализует оптимистическую
|
||||
проверку, а `refresh_locked_until` — короткую аренду продолжительностью 30
|
||||
секунд. Это предотвращает одновременное использование одного refresh-токена
|
||||
несколькими воркерами.
|
||||
|
||||
После успешного обновления токенов воркер освобождает аренду. Если она занята,
|
||||
другой воркер ожидает обновления, после чего повторяет обращение к API с новой
|
||||
парой токенов.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-003/1. Диаграмма последовательности обновления OAuth-токена*
|
||||
|
||||
## Последствия
|
||||
|
||||
Сетевой запрос к OAuth выполняется вне транзакции PostgreSQL. Версия и аренда
|
||||
координируют обновление между воркерами, а повторное чтение позволяет продолжить
|
||||
работу с версией `N+1`. Дальнейшее развитие механизма защиты предусматривает
|
||||
ротацию ключей шифрования.
|
||||
@@ -0,0 +1,61 @@
|
||||
# ADR-004: слоистая организация Telegram-бота
|
||||
|
||||
**Статус:** Принято
|
||||
**Дата:** 2026-07-23
|
||||
|
||||
## Контекст
|
||||
|
||||
Telegram-бот принимает команды и callback-запросы, проверяет привязку
|
||||
пользователя, обращается к PostgreSQL и REST API Битрикс24, а затем формирует
|
||||
HTML-сообщения и inline-клавиатуры.
|
||||
|
||||
## Решение
|
||||
|
||||
Бот организован по слоям: обработчики принимают события Telegram, middleware
|
||||
добавляет проверенную привязку в контекст, сервисы реализуют прикладные
|
||||
сценарии, `BitrixClient` отвечает за OAuth и HTTP, а классы представления
|
||||
формируют HTML-тексты и inline-клавиатуры.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-004/1. UML-диаграмма основных классов решения*
|
||||
|
||||
Классы `Binding`, `OAuthCredentials`, `ClientInfo`, `DealStageFilter` и
|
||||
`DealPage` являются dataclass-моделями передачи данных. Они отделяют словари
|
||||
REST-ответов и строки БД от интерфейсов сервисов. `DealPage` дополнительно
|
||||
вычисляет признак `has_next`, который используется при построении кнопок
|
||||
пагинации.
|
||||
|
||||
Основные команды и callback-действия Telegram-бота приведены в таблице.
|
||||
|
||||
| Ввод | Обработчик | Результат |
|
||||
|-----------------------------------------|---------------------------------|--------------------------------------------|
|
||||
| /start, /help | StartBotHandlers.start | Справка или состояние привязки |
|
||||
| /start bind_<token> | StartBotHandlers.bind | Погашение одноразовой ссылки в личном чате |
|
||||
| /deals, /leads | DealBotHandlers.deals | Первая страница сделок начальной стадии |
|
||||
| /deal ID, /lead ID | DealBotHandlers.deal_by_command | Карточка сделки по идентификатору |
|
||||
| deals:page:<stage>:<page> | deals_page | Фильтрация по стадии и пагинация |
|
||||
| deal:view:<id> | deal_by_button | Карточка выбранной сделки |
|
||||
| deal:assign:<id>:<expected> | assign_responsible | Назначение текущего Битрикс-пользователя |
|
||||
| deal:remind:<id> | remind_to_call | Создание дела на звонок через час |
|
||||
| deal:history:<id> | show_history | Пять последних переходов по стадиям |
|
||||
|
||||
*Таблица ADR-004/1. Пользовательские команды и callback-действия*
|
||||
|
||||
Навигация по страницам списка, переход к карточке и возврат выполняются кнопками
|
||||
клавиатуры с редактированием исходного сообщения бота. Список сделок также
|
||||
поддерживает фильтрацию, которая задаётся дополнительными кнопками на странице
|
||||
просмотра списка.
|
||||
|
||||
Перед выполнением CRM-команд `BindingRequiredMiddleware` ищет привязку по
|
||||
Telegram user id. При успешной проверке объект `Binding` помещается в словарь
|
||||
`data` и передаётся именованным параметром обработчика. Если привязки нет,
|
||||
цепочка прерывается до REST-запроса, а пользователь получает инструкцию открыть
|
||||
приложение в Битрикс24.
|
||||
|
||||
## Последствия
|
||||
|
||||
Конструкторы принимают зависимости явно, поэтому сервисы можно тестировать с
|
||||
имитационными репозиториями и REST-клиентами. Проверка middleware ограждает
|
||||
пользователя от ошибочного поведения и гарантирует наличие привязки перед
|
||||
обращением к CRM.
|
||||
@@ -0,0 +1,83 @@
|
||||
# ADR-005: получение списка и карточки сделки из Битрикс24
|
||||
|
||||
**Статус:** Принято
|
||||
**Дата:** 2026-07-23
|
||||
|
||||
## Контекст
|
||||
|
||||
Приложение не копирует CRM-данные в локальную базу. Сведения о сделках,
|
||||
контактах, компаниях, стадиях и истории запрашиваются через REST API
|
||||
непосредственно в момент действия пользователя. PostgreSQL хранит только данные,
|
||||
необходимые для идентификации пользователя и выполнения авторизованных запросов.
|
||||
|
||||
Реализация работает с сущностью сделки и методами `crm.deal.*`. Команды `/leads`
|
||||
и `/lead` используются как пользовательские псевдонимы `/deals` и `/deal`.
|
||||
|
||||
## Решение
|
||||
|
||||
Для получения и изменения данных применяются следующие методы REST API
|
||||
Битрикс24.
|
||||
|
||||
| Метод | Назначение | Ключевые параметры |
|
||||
|-----------------------|-------------------------------|----------------------------------------|
|
||||
| crm.deal.list | Список и пагинация | filter, select, order, start |
|
||||
| crm.deal.get | Карточка и контроль состояния | id |
|
||||
| crm.deal.update | Ответственный и стадия | id, fields, REGISTER_HISTORY_EVENT |
|
||||
| crm.status.list | Стадии воронки и источники | ENTITY_ID, STATUS_ID |
|
||||
| crm.contact.get | ФИО и телефон контакта | id |
|
||||
| crm.company.get | Название и телефон компании | id |
|
||||
| crm.stagehistory.list | История переходов | entityTypeId=2, OWNER_ID |
|
||||
| crm.activity.todo.add | Отложенный звонок | ownerTypeId=2, deadline, responsibleId |
|
||||
|
||||
*Таблица ADR-005/1. Используемые методы REST API Битрикс24*
|
||||
|
||||
### Формирование списка
|
||||
|
||||
Названия стадий не зашиты в интерфейсе. Метод `crm.status.list` получает
|
||||
актуальную конфигурацию воронки, после чего первая стадия трактуется как
|
||||
псевдофильтр `new`. Карта стадий кэшируется в памяти на 300 секунд отдельно для
|
||||
портала, пользователя и категории. Дополнительно добавляется фильтр «Все», не
|
||||
передающий `STAGE_ID` в Битрикс24.
|
||||
|
||||
Размер страницы Telegram равен пяти сделкам, тогда как Битрикс24 может
|
||||
возвращать другое количество элементов за запрос. `DealService` собирает
|
||||
REST-страницы по полю `next` до тех пор, пока не сможет выделить диапазон
|
||||
`[page * limit; page * limit + limit)`. Значение `total` используется для
|
||||
расчёта общего числа страниц. Список сделок запрашивается методом
|
||||
`crm.deal.list` с параметрами фильтрации.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-005/1. Список сделок в интерфейсе Telegram*
|
||||
|
||||
### Формирование карточки
|
||||
|
||||
Получение карточки сделки продолжает сценарий работы со списком.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-005/2. Диаграмма последовательности просмотра списка и карточки
|
||||
сделки*
|
||||
|
||||
Карточка загружается методом `crm.deal.get`, затем обогащается данными связанных
|
||||
сущностей. Для контакта составляется ФИО и выбирается первый телефон; при
|
||||
отсутствии телефона контакта проверяется компания. Идентификаторы источника и
|
||||
стадии преобразуются в человекочитаемые названия. В итоговое сообщение
|
||||
включаются сумма, валюта, ответственный, дата создания и комментарий.
|
||||
|
||||
Все динамические строки перед включением в HTML-ответ Telegram проходят
|
||||
`html.escape`. Длина карточки ограничена 3900 символами, что оставляет запас до
|
||||
ограничения Telegram и предотвращает ошибку отправки из-за длинного комментария.
|
||||
Кнопка назначения отображается только для новой сделки, если текущий
|
||||
пользователь ещё не является ответственным.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-005/3. Карточка сделки в интерфейсе Telegram*
|
||||
|
||||
## Последствия
|
||||
|
||||
Битрикс24 остаётся источником актуальных CRM-данных, а локальная база не требует
|
||||
синхронизации сделок и связанных сущностей. В качестве дальнейшего развития
|
||||
предусмотрен переход с устаревающих методов `crm.deal.*` на универсальные методы
|
||||
`crm.item.*`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR-006: изменение сделки с проверкой актуального состояния
|
||||
|
||||
**Статус:** Принято
|
||||
**Дата:** 2026-07-23
|
||||
|
||||
## Контекст
|
||||
|
||||
Состояние сделки может измениться в Битрикс24 после формирования карточки в
|
||||
Telegram, но до нажатия callback-кнопки. Используемый метод `crm.deal.update` не
|
||||
предоставляет условный `UPDATE`, поэтому перед изменением требуется проверить,
|
||||
что показанное пользователю состояние остаётся актуальным.
|
||||
|
||||
## Решение
|
||||
|
||||
### Назначение ответственного и изменение стадии
|
||||
|
||||
Callback-кнопка назначения содержит не только id сделки, но и `ASSIGNED_BY_ID`,
|
||||
который был показан пользователю:
|
||||
`deal:assign:<deal_id>:<expected_responsible_id>`. Перед изменением сервис
|
||||
повторно загружает сделку и сравнивает фактического ответственного с ожидаемым.
|
||||
Если карточка устарела, REST-обновление не выполняется.
|
||||
|
||||
Внутри одного процесса операции по паре `(member_id, deal_id)` последовательно
|
||||
выполняются под `asyncio.Lock`. После `crm.deal.update` сервис повторно читает
|
||||
сделку и убеждается, что ответственным стал `bitrix_user_id` привязанного
|
||||
пользователя.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-006/1. Диаграмма последовательности взятия сделки в работу*
|
||||
|
||||
При обновлении одновременно передаются `ASSIGNED_BY_ID` связанного пользователя,
|
||||
рабочая `STAGE_ID` и параметр `REGISTER_HISTORY_EVENT=Y`. После REST-запроса
|
||||
выполняется контрольное чтение сделки.
|
||||
|
||||
### Планирование звонка и просмотр истории
|
||||
|
||||
Действие «Позвонить позже» создаёт в Битрикс24 дело типа `todo` с крайним сроком
|
||||
через один час. Владельцем является сделка (`ownerTypeId=2`), а ответственным —
|
||||
связанный пользователь Битрикс24. Массив `pingOffsets=[0]` включает напоминание
|
||||
в момент наступления срока.
|
||||
|
||||
История загружается методом `crm.stagehistory.list` с фильтром `OWNER_ID` и
|
||||
сортировкой по убыванию идентификатора. В интерфейс выводятся первые пять
|
||||
событий. Для каждого события идентификатор стадии преобразуется в название с
|
||||
учётом `CATEGORY_ID`, после чего `DealFormatter` формирует защищённый HTML-текст
|
||||
и клавиатуру возврата к карточке или списку.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-006/2. Диаграмма последовательности планирования звонка и просмотра
|
||||
истории*
|
||||
|
||||
## Последствия
|
||||
|
||||
Локальный `asyncio.Lock` защищает только один процесс `bot`. При горизонтальном
|
||||
масштабировании на несколько контейнеров потребуется распределённая блокировка
|
||||
либо серверная условная операция. Повторная проверка REST-результата сохраняет
|
||||
защиту от внешних изменений, но не делает два удалённых вызова одной транзакцией
|
||||
Битрикс24.
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-007: развёртывание приложения с помощью Docker Compose
|
||||
|
||||
**Статус:** Принято
|
||||
**Дата:** 2026-07-23
|
||||
|
||||
## Контекст
|
||||
|
||||
Программное решение состоит из PostgreSQL, сервиса миграций, Telegram-бота и
|
||||
Flask-сайта. База данных должна быть готова до запуска приложений, а
|
||||
SQL-миграции должны выполняться последовательно с сохранением истории
|
||||
применения.
|
||||
|
||||
## Решение
|
||||
|
||||
Для развёртывания используется Docker Compose из четырёх основных сервисов:
|
||||
`db`, `site`, `bot` и `migrate`. Сервисы базы данных и миграций используют
|
||||
готовые образы на основе Alpine Linux. Модули приложения собираются с помощью
|
||||
Dockerfile на базе среды выполнения Python 3.13 и включают необходимые
|
||||
библиотеки.
|
||||
|
||||
Первым запускается контейнер `db` с PostgreSQL, который также импортирует
|
||||
первичные настройки ролей. Контейнер PostgreSQL не публикует порт на хост и
|
||||
остаётся доступным только внутри сети Compose.
|
||||
|
||||
Затем запускается контейнер `migrate`. Он последовательно применяет SQL-скрипты
|
||||
из `db/migrations` и сохраняет историю их применения в таблице
|
||||
`public.schema_migrations`. Для каждой миграции хранится контрольная сумма. Если
|
||||
уже применённый файл был изменён, выполнение завершается с ошибкой.
|
||||
|
||||
После успешного завершения миграций запускаются Telegram-бот и Flask-сайт.
|
||||
Условиями их запуска являются нормальное состояние контейнера базы данных и
|
||||
успешное завершение контейнера `migrate`.
|
||||
|
||||
Сайт доступен только через loopback-адрес `127.0.0.1`. Внешний nginx принимает
|
||||
HTTPS-трафик и передаёт заголовки `X-Forwarded-*`. Бот не имеет входящего порта
|
||||
и получает обновления методом long polling.
|
||||
|
||||
Контейнеры приложения выполняются от системного пользователя `runtime`, а не от
|
||||
`root`.
|
||||
|
||||
## Последствия
|
||||
|
||||
Миграции выполняются до запуска прикладных процессов. PostgreSQL не публикуется
|
||||
наружу, сайт доступен извне только через HTTPS-прокси, а Telegram-бот не требует
|
||||
входящего сетевого порта. Изменение уже применённой миграции обнаруживается по
|
||||
несовпадению контрольной суммы.
|
||||
@@ -0,0 +1,70 @@
|
||||
# ADR-008: хранение интеграционных данных и разграничение доступа
|
||||
|
||||
**Статус:** Принято
|
||||
**Дата:** 2026-07-23
|
||||
|
||||
## Контекст
|
||||
|
||||
Основное назначение базы данных — хранение авторизационных данных, токенов
|
||||
привязки, OAuth-токенов и связей между пользователями Битрикс24 и Telegram.
|
||||
Сделки, контакты, компании, стадии и история остаются в Битрикс24 и в локальной
|
||||
базе не дублируются.
|
||||
|
||||
## Решение
|
||||
|
||||
Модель хранения нормализована вокруг портала Битрикс24.
|
||||
|
||||

|
||||
|
||||
*Рисунок ADR-008/1. Схема базы данных*
|
||||
|
||||
| Таблица | Ключевые данные | Назначение и ограничения |
|
||||
|------------------------|-------------------------------------------------|-------------------------------------------------------|
|
||||
| binding.portals | member_id, domain | Справочник порталов; member_id уникален |
|
||||
| binding.tokens | token_hash, expires_at, consumed_at, revoked_at | Одноразовые ссылки; токен хранится только как хеш |
|
||||
| binding.user_bindings | portal_id, bitrix_user_id, telegram_user_id | Однозначная привязка пользователей в пределах портала |
|
||||
| oauth.user_credentials | access_token, refresh_token, version, lock | Зашифрованные OAuth-данные и координация обновления |
|
||||
|
||||
*Таблица ADR-008/1. Назначение таблиц базы данных*
|
||||
|
||||
Поле `member_id` является устойчивым внешним идентификатором портала, а числовой
|
||||
`bitrix_user_id` имеет смысл только вместе с `portal_id`. Связи с `portals`
|
||||
используют `ON DELETE CASCADE`: удаление портала автоматически удаляет его
|
||||
ссылки, привязки и OAuth-данные. Составной первичный ключ
|
||||
`oauth.user_credentials(portal_id, bitrix_user_id)` исключает две конкурирующие
|
||||
записи учётных данных одного пользователя. Поля `consumed_at` и `revoked_at`
|
||||
разделяют два независимых основания недействительности одноразовой ссылки.
|
||||
|
||||
Для работы с данными в PostgreSQL созданы две роли: `site_role` и `bot_role`.
|
||||
Они имеют разные права доступа к хранимым функциям и не могут редактировать
|
||||
таблицы напрямую.
|
||||
|
||||
| Функция | Вызывающая роль | Назначение |
|
||||
|-----------------------------|-----------------|-------------------------------------------------------------------------------------|
|
||||
| binding.issue_v1 | site_role | Сохранить OAuth-данные, отозвать старые ссылки и выпустить новую в одной транзакции |
|
||||
| binding.consume_v1 | bot_role | Однократно погасить ссылку и создать привязку |
|
||||
| binding.find_by_telegram_v1 | bot_role | Получить актуальную привязку Telegram |
|
||||
| oauth.get_credentials_v1 | bot_role | Получить учётные данные связанного пользователя |
|
||||
| oauth.claim_refresh_v1 | bot_role | Получить короткую аренду на обновление токена |
|
||||
| oauth.finish_refresh_v1 | bot_role | Атомарно записать новую пару при совпадении версии |
|
||||
| oauth.release_refresh_v1 | bot_role | Освободить аренду после ошибки |
|
||||
|
||||
*Таблица ADR-008/2. Контракт хранимых функций PostgreSQL*
|
||||
|
||||
Сайт использует роль `site_role` и может вызывать только функцию привязки.
|
||||
Telegram-бот использует `bot_role` и может вызывать функции погашения токенов,
|
||||
получения привязки и работы с OAuth. Роль `site_role` не имеет доступа к схеме
|
||||
`oauth` и не может читать сохранённые токены; роль `bot_role` не может выпускать
|
||||
новые ссылки от имени сайта.
|
||||
|
||||
Все функции объявлены `SECURITY DEFINER` и фиксируют `search_path` в
|
||||
`pg_catalog`, что уменьшает риск подмены объектов. Права `PUBLIC` на таблицы и
|
||||
функции отозваны.
|
||||
|
||||
## Последствия
|
||||
|
||||
Конкурирующие вызовы `binding.consume_v1` для одного хеша не смогут одновременно
|
||||
пройти условие `consumed_at IS NULL`: `UPDATE` блокирует строку, а после
|
||||
завершения первой транзакции второй вызов видит уже установленное время
|
||||
погашения. Проверка и изменение не разделены между приложением и базой, поэтому
|
||||
отсутствует окно гонки между `SELECT` и `UPDATE`.
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -1,13 +1,43 @@
|
||||
# Архитектурные решения (ADR)
|
||||
# Архитектурные решения BitrixDealsBot
|
||||
|
||||
## Введение
|
||||
В ходе производственной практики разработано серверное приложение
|
||||
BitrixDealsBot, которое предоставляет менеджеру по продажам интерфейс Telegram
|
||||
для работы со сделками CRM Битрикс24. Разработанное решение не копирует
|
||||
CRM-данные в локальную базу: все сведения о сделках, контактах, компаниях,
|
||||
стадиях и истории запрашиваются через REST API непосредственно в момент действия
|
||||
пользователя. PostgreSQL хранит только данные, необходимые для идентификации
|
||||
пользователя Битрикс24 и его привязки к пользователю Telegram-бота, включая
|
||||
OAuth-токены.
|
||||
|
||||
Данный раздел содержит архитектурные решения (ADR) для проекта.
|
||||
Архитектурные решения описывают ключевые решения, принятые в процессе разработки
|
||||
системы, включая выбор технологий, подходов и структурных решений.
|
||||
В индивидуальном задании используется термин «лид», однако реализация работает с
|
||||
сущностью сделки и методами `crm.deal.*`. Команды `/leads` и `/lead` сохранены
|
||||
как пользовательские псевдонимы `/deals` и `/deal`, поэтому интерфейс остаётся
|
||||
совместимым с формулировкой задания, а в документации используется технически
|
||||
точное понятие «сделка».
|
||||
|
||||
## Оглавление
|
||||
## Технологический стек
|
||||
|
||||
**В данном разделе представлены следующие архитектурные решения:**
|
||||
| Уровень | Технология | Назначение |
|
||||
|---------------|---------------------------|--------------------------------------------------------------------------|
|
||||
| Язык | Python 3.13 | Серверная логика сайта и Telegram-бота |
|
||||
| Telegram | aiogram 3 | Асинхронная маршрутизация команд, callback-запросов и опроса сервера |
|
||||
| HTTP-сервер | Flask 3 + Gunicorn | Страница привязки пользователя Битрикс24 к конкретному аккаунту Telegram |
|
||||
| Запросы к API | httpx | Синхронные и асинхронные запросы к OAuth и REST API Битрикс24 |
|
||||
| Хранилище | PostgreSQL 17 + Psycopg 3 | Транзакции, хранимые функции и пулы соединений |
|
||||
| Защита | Fernet + SHA-256 | Шифрование OAuth-токенов и хеширование одноразовых ссылок |
|
||||
| Развёртывание | Docker Compose + nginx | Изоляция процессов, миграции, HTTPS и обратное проксирование |
|
||||
|
||||
_(В процессе разработки будут добавляться новые решения)_
|
||||
*Таблица 1. Технологический стек решения*
|
||||
|
||||
## Состав группы ADR
|
||||
|
||||
| ADR | Архитектурное решение | Статус |
|
||||
|----------------------------------------------|-----------------------------------------------------------|---------|
|
||||
| [ADR-001](001-apps-and-database.md) | Разделение приложения на сайт, Telegram-бот и базу данных | Принято |
|
||||
| [ADR-002](002-oauth-telegram-binding.md) | Привязка пользователей Битрикс24 и Telegram | Принято |
|
||||
| [ADR-003](003-oauth-credential-lifecycle.md) | Защита и обновление OAuth-токенов | Принято |
|
||||
| [ADR-004](004-bot-layers.md) | Слоистая организация Telegram-бота | Принято |
|
||||
| [ADR-005](005-bitrix-deal-read-model.md) | Получение списка и карточки сделки из Битрикс24 | Принято |
|
||||
| [ADR-006](006-guarded-deal-mutations.md) | Изменение сделки с проверкой актуального состояния | Принято |
|
||||
| [ADR-007](007-container-deployment.md) | Развёртывание приложения с помощью Docker Compose | Принято |
|
||||
| [ADR-008](008-data-storage-and-access.md) | Хранение интеграционных данных и разграничение доступа | Принято |
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
httpx~=0.28.1
|
||||
aiogram~=3.29.1
|
||||
python-dotenv~=1.2.2
|
||||
python-dotenv~=1.2.2
|
||||
Flask>=3.1,<4
|
||||
gunicorn>=23,<24
|
||||
psycopg[binary,pool]>=3.2,<4
|
||||
cryptography>=44,<48
|
||||
werkzeug>=3.1,<4
|
||||