Добавление всех наработок за период практики.
@@ -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
|
# 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
|
# 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.
|
# 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 — это телеграм-бот, который позволяет пользователям
|
**BitrixDealsBot** — это телеграм-бот, который позволяет пользователям
|
||||||
взаимодействовать с CRM-системой Bitrix24 для управления сделками и контактами.
|
взаимодействовать с CRM-системой Bitrix24 для управления сделками и контактами.
|
||||||
Цель бота — упростить процесс работы с CRM, предоставляя удобный интерфейс для
|
Цель бота — упростить процесс работы с CRM, предоставляя удобный интерфейс для
|
||||||
обновления и отслеживания сделок прямо из Telegram.
|
обновления и отслеживания сделок прямо из Telegram.
|
||||||
|
|
||||||
Задание выполняется в рамках учебной производственной практики для предприятия
|
Задание выполняется в рамках учебной производственной практики для предприятия
|
||||||
Интерволга.
|
ООО "Интернет-агенство ИНТЕРВОЛГА".
|
||||||
|
|
||||||
## Формулировка задания
|
## Формулировка задания
|
||||||
|
|
||||||
**Telegram-бот “Помощник менеджера CRM”**
|
**Telegram-бот “Помощник менеджера CRM”**
|
||||||
|
|
||||||
Telegram-бот для менеджера по продажам. Бот помогает быстро смотреть новые лиды,
|
Telegram-бот для менеджера по продажам. Бот помогает быстро смотреть новые лиды,
|
||||||
брать их в работу, менять статус и добавлять комментарии.
|
брать их в работу, менять статус и добавлять комментарии.
|
||||||
|
|
||||||
**Стек:** Любой язык, любая БД, REST API Telegram, REST API Битрикс24
|
**Стек:** Любой язык, любая БД, REST API Telegram, REST API Битрикс24
|
||||||
|
|
||||||
**Функции:**
|
**Функции:**
|
||||||
|
|
||||||
- команда /leads показывает новые лиды (без ответственных);
|
- команда /leads показывает новые лиды (без ответственных);
|
||||||
- команда /lead ### показывает карточку лида по указанному ID: имя, телефон, источник, статус;
|
- команда /lead ### показывает карточку лида по указанному ID: имя, телефон,
|
||||||
|
источник, статус;
|
||||||
- кнопка “Взять” устанавливает ответственного;
|
- кнопка “Взять” устанавливает ответственного;
|
||||||
- кнопка “Позвонить позже” устанавливает ответственного и планирует звонок через 1 час;
|
- кнопка “Позвонить позже” устанавливает ответственного и планирует звонок через
|
||||||
|
1 час;
|
||||||
- кнопка “Закрыть” возвращает в список лидов /leads;
|
- кнопка “Закрыть” возвращает в список лидов /leads;
|
||||||
- команда /history показывает историю действий;
|
- команда /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
|
httpx~=0.28.1
|
||||||
aiogram~=3.29.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
|
||||||