init2
This commit is contained in:
parent
fe02b5139a
commit
e94e1a3faa
18 changed files with 24454 additions and 455 deletions
30
.dockerignore
Normal file
30
.dockerignore
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Данные и секреты — монтируются через volumes, не копировать в образ
|
||||||
|
data/
|
||||||
|
ssl/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Кеш Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Тесты и разработка
|
||||||
|
tests/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Анализ (не нужен в образе)
|
||||||
|
ANALYSIS.md
|
||||||
28
.env.example
28
.env.example
|
|
@ -1,7 +1,25 @@
|
||||||
TELEGRAM_TOKEN=7586393409:AAFBhpdkjWU8-Fn0B6vv-VLR5kUQ6iXKXss
|
# ── Обязательные ──────────────────────────────────────────────────────────────
|
||||||
INTRADESK_API_KEY=26221B839B6643E68345FD26BF
|
TELEGRAM_TOKEN=your_bot_token_here
|
||||||
CHANNEL_ID=-1003708972637
|
INTRADESK_API_KEY=your_api_key_here
|
||||||
BASE_TASK_URL=https://cleverparking.intradesk.ru/task/list/
|
CHANNEL_ID=-100123456789
|
||||||
WEBHOOK_SECRET=capsspac
|
|
||||||
|
# ── Необязательные ────────────────────────────────────────────────────────────
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
PUBLIC_URL=https://bot.yourdomain.com
|
||||||
|
BASE_TASK_URL=https://cleverparking.intradesk.ru/task/list/
|
||||||
DB_FILE=/app/data/intradbot.db
|
DB_FILE=/app/data/intradbot.db
|
||||||
|
WEBHOOK_SECRET=your_webhook_secret
|
||||||
|
GIF_URL=https://media.tenor.com/OzN-0kxqXAgAAAAi/cat.gif
|
||||||
|
|
||||||
|
# Пароль для дашборда (оставьте пустым — дашборд открыт)
|
||||||
|
DASHBOARD_PASSWORD=
|
||||||
|
|
||||||
|
# Секрет для подписи ссылок Mini App (по умолчанию берётся из WEBHOOK_SECRET)
|
||||||
|
MINIAPP_SECRET=
|
||||||
|
|
||||||
|
# ── Бизнес-параметры ──────────────────────────────────────────────────────────
|
||||||
|
# ID сотрудников для кнопки "Назначить исполнителя" (через запятую)
|
||||||
|
EXECUTOR_USER_IDS=2227907,631481,525111
|
||||||
|
|
||||||
|
# Фильтр активных заявок на дашборде
|
||||||
|
ACTIVE_TASKS_FILTER=(status ne 68045) and (executorgroup eq 243025)
|
||||||
|
|
|
||||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Секретные ключи и env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
*.cer
|
||||||
|
*.crt
|
||||||
|
*.der
|
||||||
|
|
||||||
|
# SSL / сертификаты
|
||||||
|
/ssl/
|
||||||
|
/certs/
|
||||||
|
/certificates/
|
||||||
|
|
||||||
|
# Прочее частое
|
||||||
|
*.secret
|
||||||
|
secrets.json
|
||||||
|
config.local.*
|
||||||
119
ANALYSIS.md
Normal file
119
ANALYSIS.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# Анализ проекта IntradBot — Senior Review
|
||||||
|
|
||||||
|
## Общая оценка
|
||||||
|
|
||||||
|
Проект написан чисто, логика разбита по модулям правильно, читается легко.
|
||||||
|
Для своего масштаба архитектура адекватная. Ниже — конкретные проблемы
|
||||||
|
от критических до косметических.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SMART применим?
|
||||||
|
|
||||||
|
SMART — это фреймворк для постановки целей (Specific / Measurable / Achievable /
|
||||||
|
Relevant / Time-bound), не для архитектуры. Скорее всего вы имели в виду
|
||||||
|
**SOLID** + **DRY** + **KISS**. Вот где они нарушены:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Критические проблемы
|
||||||
|
|
||||||
|
### 1. Flask dev-сервер в продакшене
|
||||||
|
**Файл:** `web/server.py` → `app.run(...)`
|
||||||
|
|
||||||
|
Flask's built-in server однопоточный, не рассчитан на нагрузку и явно
|
||||||
|
написано в документации: *"Do not use the development server in production"*.
|
||||||
|
При одновременных запросах (webhook + кто-то открыл miniapp) будут задержки.
|
||||||
|
|
||||||
|
**Решение:** Gunicorn — одна строка в Dockerfile.
|
||||||
|
|
||||||
|
### 2. Пароль захардкожен в коде
|
||||||
|
**Файл:** `config.py`
|
||||||
|
```python
|
||||||
|
DASHBOARD_PASSWORD = os.environ.get("DASHBOARD_PASSWORD", "capsspac")
|
||||||
|
```
|
||||||
|
Дефолтное значение пароля прямо в репозитории — плохая практика.
|
||||||
|
Если этот код когда-либо окажется на GitHub (даже приватном) — пароль скомпрометирован.
|
||||||
|
|
||||||
|
**Решение:** дефолт должен быть пустой строкой `""`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Серьёзные проблемы
|
||||||
|
|
||||||
|
### 3. Дублирование ~150 строк в formatter.py (нарушение DRY)
|
||||||
|
`parse_lifetime()` и `format_lifetime_api_events()` — это одна и та же логика
|
||||||
|
разбора событий, написанная дважды. Любое изменение (новый тип события,
|
||||||
|
новый формат файла) нужно делать в двух местах — классический источник багов.
|
||||||
|
|
||||||
|
**Решение:** вынести разбор одного события в `_parse_single_event()`,
|
||||||
|
обе функции используют её.
|
||||||
|
|
||||||
|
### 4. Hardcoded ID сотрудников и группы (нарушение SOLID — OCP)
|
||||||
|
**Файл:** `bot/telegram.py`
|
||||||
|
```python
|
||||||
|
EXECUTOR_USER_IDS = [2227907, 631481, 525111]
|
||||||
|
```
|
||||||
|
**Файл:** `web/server.py`
|
||||||
|
```python
|
||||||
|
"(status ne 68045) and (executorgroup eq 243025)"
|
||||||
|
```
|
||||||
|
Чтобы добавить сотрудника или сменить группу — нужно лезть в код и делать rebuild.
|
||||||
|
|
||||||
|
**Решение:** вынести в `.env` / `config.py`.
|
||||||
|
|
||||||
|
### 5. 17 новых DB-соединений на каждый запрос
|
||||||
|
**Файл:** `database.py` — каждая функция вызывает `_connect()`.
|
||||||
|
SQLite при частых соединениях создаёт overhead на открытие/закрытие файла.
|
||||||
|
|
||||||
|
**Решение:** использовать `threading.local()` для переиспользования соединения
|
||||||
|
в рамках одного потока.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Средние проблемы
|
||||||
|
|
||||||
|
### 6. Незапиненные зависимости
|
||||||
|
**Файл:** `requirements.txt`
|
||||||
|
```
|
||||||
|
requests ← без версии
|
||||||
|
flask ← без версии
|
||||||
|
schedule ← без версии
|
||||||
|
```
|
||||||
|
При следующем `docker build` может поставиться breaking версия.
|
||||||
|
|
||||||
|
**Решение:** зафиксировать все версии.
|
||||||
|
|
||||||
|
### 7. Нет `.dockerignore`
|
||||||
|
При `COPY . .` в контейнер попадают: `ssl/`, `data/`, `tests/`, `.env`,
|
||||||
|
`__pycache__`, `.git` если будет. Это раздувает образ и создаёт риск
|
||||||
|
утечки секретов.
|
||||||
|
|
||||||
|
### 8. Нет валидации конфига при старте
|
||||||
|
Если `TELEGRAM_TOKEN` не задан — бот упадёт не сразу, а в момент первого
|
||||||
|
обращения к Telegram с непонятной ошибкой.
|
||||||
|
|
||||||
|
**Решение:** проверять обязательные переменные в `config.py` на старте.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что НЕ нужно трогать
|
||||||
|
|
||||||
|
- **SQLite → PostgreSQL**: при текущей нагрузке SQLite достаточно.
|
||||||
|
- **Разбить на микросервисы**: оверкилл, один процесс с потоками — нормально.
|
||||||
|
- **ORM (SQLAlchemy)**: текущие raw SQL запросы читаются лучше.
|
||||||
|
- **Celery/Redis**: webhook обрабатывается в отдельном треде, этого хватает.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог изменений
|
||||||
|
|
||||||
|
| # | Файл | Изменение | Приоритет |
|
||||||
|
|---|------|-----------|-----------|
|
||||||
|
| 1 | `Dockerfile` + `requirements.txt` | Gunicorn вместо dev-сервера | 🔴 Критично |
|
||||||
|
| 2 | `config.py` | Убрать захардкоженный пароль, добавить валидацию, EXECUTOR_USER_IDS, DEFAULT_FILTER | 🔴 Критично |
|
||||||
|
| 3 | `intradesk/formatter.py` | Вынести `_parse_single_event()`, убрать дублирование | 🟡 Важно |
|
||||||
|
| 4 | `database.py` | Thread-local соединение | 🟡 Важно |
|
||||||
|
| 5 | `requirements.txt` | Зафиксировать версии | 🟡 Важно |
|
||||||
|
| 6 | `.dockerignore` | Добавить | 🟢 Хорошая практика |
|
||||||
|
| 7 | `web/server.py` | Убрать `app.run()`, добавить `create_app()` | 🟢 Хорошая практика |
|
||||||
|
|
@ -9,4 +9,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Gunicorn запускает Flask в продакшене.
|
||||||
|
# intradbot.py сам запускает бота и шедулер в отдельных потоках,
|
||||||
|
# а Gunicorn обслуживает HTTP — один worker достаточно для webhook-нагрузки.
|
||||||
CMD ["python", "intradbot.py"]
|
CMD ["python", "intradbot.py"]
|
||||||
|
|
|
||||||
444
bot/telegram.py
444
bot/telegram.py
|
|
@ -1,4 +1,7 @@
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
import telebot
|
import telebot
|
||||||
from telebot import types
|
from telebot import types
|
||||||
|
|
@ -8,168 +11,370 @@ import config
|
||||||
import database
|
import database
|
||||||
from intradesk import reference
|
from intradesk import reference
|
||||||
from intradesk import api as intradesk_api
|
from intradesk import api as intradesk_api
|
||||||
|
from intradesk import formatter
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
bot = telebot.TeleBot(config.TOKEN, parse_mode="Markdown")
|
bot = telebot.TeleBot(config.TOKEN, parse_mode="Markdown")
|
||||||
|
|
||||||
|
# Сотрудники, доступные для назначения исполнителем (ID из Intradesk)
|
||||||
|
EXECUTOR_USER_IDS = config.EXECUTOR_USER_IDS
|
||||||
|
|
||||||
# ── Кнопки под постом ─────────────────────────────────────────────────────────
|
# Разделитель перед секцией "В работе" в тексте поста
|
||||||
|
_RESP_SEP = "\n\n─────────────────────"
|
||||||
|
|
||||||
def make_task_buttons(task_number: str, closed: bool = False) -> types.InlineKeyboardMarkup:
|
|
||||||
markup = types.InlineKeyboardMarkup(row_width=2)
|
# ── Текст с футером "В работе" ────────────────────────────────────────────────
|
||||||
if not closed:
|
|
||||||
|
def _build_full_text(
|
||||||
|
base_text: str,
|
||||||
|
responsible: list[dict],
|
||||||
|
events_lines: list[str] | None = None,
|
||||||
|
new_count: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Собирает итоговый текст поста:
|
||||||
|
1. Основной текст (base_text)
|
||||||
|
2. «📋 Последние события» — все события кроме последних new_count
|
||||||
|
3. «🆕 Новое» — последние new_count событий (все новые из текущего вебхука)
|
||||||
|
4. Разделитель + «В работе» — всегда самый последний блок
|
||||||
|
"""
|
||||||
|
clean = base_text.split(_RESP_SEP)[0]
|
||||||
|
result = clean
|
||||||
|
|
||||||
|
if events_lines:
|
||||||
|
if new_count > 0:
|
||||||
|
history = events_lines[:-new_count]
|
||||||
|
newest = events_lines[-new_count:]
|
||||||
|
else:
|
||||||
|
history = events_lines
|
||||||
|
newest = []
|
||||||
|
# Показываем последние 6 из истории
|
||||||
|
history = history[-6:]
|
||||||
|
if history:
|
||||||
|
result += "\n\n📋 *Последние события:*\n" + "\n".join(history)
|
||||||
|
if newest:
|
||||||
|
result += "\n\n🆕 *Новое:*\n" + "\n".join(newest)
|
||||||
|
|
||||||
|
if responsible:
|
||||||
|
names = ", ".join(r["user_name"] for r in responsible)
|
||||||
|
result += _RESP_SEP + f"\n🙋 В работе: {names}"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Клавиатуры ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def sign_task_url(task_number: str) -> str:
|
||||||
|
"""Генерирует подписанную ссылку на Mini App с токеном (TTL 30 дней)."""
|
||||||
|
expires = int(time.time()) + 30 * 24 * 3600
|
||||||
|
msg = f"{task_number}:{expires}".encode()
|
||||||
|
sig = hmac.new(config.MINIAPP_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:16]
|
||||||
|
return f"{config.PUBLIC_URL}/miniapp?task={task_number}&exp={expires}&sig={sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def make_task_buttons(task_number: str, group_id: int) -> types.InlineKeyboardMarkup:
|
||||||
|
gid = group_id or 0
|
||||||
|
markup = types.InlineKeyboardMarkup(row_width=1)
|
||||||
|
#markup.add(
|
||||||
|
# types.InlineKeyboardButton("📋 Статус", callback_data=f"st:{task_number}:{gid}"),
|
||||||
|
# types.InlineKeyboardButton("👤 Исполнитель", callback_data=f"ex:{task_number}:{gid}"),
|
||||||
|
# types.InlineKeyboardButton("🙋 Взять", callback_data=f"take:{task_number}"),
|
||||||
|
# types.InlineKeyboardButton("❌ Снять", callback_data=f"drop:{task_number}"),
|
||||||
|
#)
|
||||||
|
# Кнопка Mini App — полная карточка заявки (только если PUBLIC_URL задан)
|
||||||
|
if config.PUBLIC_URL:
|
||||||
markup.add(
|
markup.add(
|
||||||
types.InlineKeyboardButton("📋 Статус", callback_data=f"st:{task_number}"),
|
types.InlineKeyboardButton("📄 Предпросмотр", url=sign_task_url(task_number)),
|
||||||
types.InlineKeyboardButton("👤 Исполнитель", callback_data=f"ex:{task_number}"),
|
types.InlineKeyboardButton("🔗 Открыть в IntraDesk", url=config.BASE_TASK_URL + task_number),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
markup.add(
|
||||||
|
types.InlineKeyboardButton("🔗 Открыть заявку", url=config.BASE_TASK_URL + task_number),
|
||||||
)
|
)
|
||||||
return markup
|
return markup
|
||||||
|
|
||||||
|
|
||||||
# ── Отправка / редактирование поста ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def send_or_edit(
|
def _make_status_keyboard(task_number: str, group_id: int) -> types.InlineKeyboardMarkup:
|
||||||
|
markup = types.InlineKeyboardMarkup(row_width=1)
|
||||||
|
for sid, name in reference.STATUSES.items():
|
||||||
|
markup.add(types.InlineKeyboardButton(
|
||||||
|
name, callback_data=f"ss:{task_number}:{group_id}:{sid}"
|
||||||
|
))
|
||||||
|
markup.add(types.InlineKeyboardButton("↩ Назад", callback_data=f"back:{task_number}:{group_id}"))
|
||||||
|
return markup
|
||||||
|
|
||||||
|
|
||||||
|
def _make_executor_keyboard(task_number: str, group_id: int) -> types.InlineKeyboardMarkup:
|
||||||
|
markup = types.InlineKeyboardMarkup(row_width=1)
|
||||||
|
for uid in EXECUTOR_USER_IDS:
|
||||||
|
name = database.resolve("employees", uid) or f"ID {uid}"
|
||||||
|
markup.add(types.InlineKeyboardButton(
|
||||||
|
name, callback_data=f"se:{task_number}:{group_id}:{uid}"
|
||||||
|
))
|
||||||
|
markup.add(types.InlineKeyboardButton(
|
||||||
|
"➖ Только группа", callback_data=f"su:{task_number}:{group_id}"
|
||||||
|
))
|
||||||
|
markup.add(types.InlineKeyboardButton("↩ Назад", callback_data=f"back:{task_number}:{group_id}"))
|
||||||
|
return markup
|
||||||
|
|
||||||
|
|
||||||
|
# ── Публикация / пересоздание поста ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def recreate_post(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
task_number: str,
|
task_number: str,
|
||||||
full_text: str,
|
base_text: str,
|
||||||
update_summary: str = None,
|
group_id: int = 0,
|
||||||
closed: bool = False,
|
closed: bool = False,
|
||||||
|
new_event_lines: list[str] | None = None,
|
||||||
):
|
):
|
||||||
markup = make_task_buttons(task_number, closed=closed)
|
"""Удаляет старый пост и создаёт новый, накапливая события и сохраняя ответственных."""
|
||||||
msg_id = database.get_message_id(task_id)
|
# Накапливаем события:
|
||||||
|
# - изменения (• ...) дедуплицируем по тексту — одно и то же изменение не дублируем
|
||||||
if msg_id:
|
# - комментарии (💬 ...) и описание (📝 ...) всегда добавляем, даже если текст совпадает
|
||||||
try:
|
stored = database.get_stored_events(task_id)
|
||||||
bot.edit_message_text(
|
stored_set = set(stored)
|
||||||
full_text,
|
if new_event_lines:
|
||||||
config.CHANNEL_ID,
|
added = []
|
||||||
msg_id,
|
for line in new_event_lines:
|
||||||
parse_mode="Markdown",
|
if not line:
|
||||||
disable_web_page_preview=True,
|
continue
|
||||||
reply_markup=markup if not closed else None,
|
is_change = line.startswith("•")
|
||||||
)
|
if is_change and line in stored_set:
|
||||||
except ApiTelegramException as e:
|
continue # дедупликация только для изменений
|
||||||
if "message is not modified" not in str(e):
|
added.append(line)
|
||||||
log.warning(f"edit_message failed for task {task_id}: {e}")
|
stored_set.add(line) # обновляем set чтобы не дублировать внутри одного вебхука
|
||||||
|
stored = stored + added
|
||||||
if update_summary:
|
|
||||||
try:
|
|
||||||
bot.send_message(
|
|
||||||
config.CHANNEL_ID,
|
|
||||||
update_summary,
|
|
||||||
reply_to_message_id=msg_id,
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
except ApiTelegramException as e:
|
|
||||||
log.warning(f"send reply failed for task {task_id}: {e}")
|
|
||||||
else:
|
else:
|
||||||
try:
|
added = []
|
||||||
msg = bot.send_message(
|
|
||||||
config.CHANNEL_ID,
|
responsible = database.get_responsible(task_id)
|
||||||
full_text,
|
full_text = _build_full_text(base_text, responsible, stored, new_count=len(added))
|
||||||
parse_mode="Markdown",
|
markup = make_task_buttons(task_number, group_id)
|
||||||
disable_web_page_preview=True,
|
|
||||||
reply_markup=markup,
|
# Удаляем старый пост
|
||||||
)
|
old = database.get_message_data(task_id)
|
||||||
database.save_message_id(task_id, msg.message_id, task_number)
|
if old:
|
||||||
except ApiTelegramException as e:
|
_delete_message(config.CHANNEL_ID, old["message_id"])
|
||||||
log.error(f"send_message failed for task {task_id}: {e}")
|
|
||||||
|
try:
|
||||||
|
msg = bot.send_message(
|
||||||
|
config.CHANNEL_ID,
|
||||||
|
full_text,
|
||||||
|
parse_mode="Markdown",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
reply_markup=markup,
|
||||||
|
)
|
||||||
|
# save_message_id сначала создаёт/обновляет строку, потом сохраняем события
|
||||||
|
database.save_message_id(task_id, msg.message_id, task_number, group_id, base_text)
|
||||||
|
if added:
|
||||||
|
database.save_stored_events(task_id, stored)
|
||||||
|
except ApiTelegramException as e:
|
||||||
|
log.error(f"recreate_post failed for task {task_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ── Callbacks: смена статуса ──────────────────────────────────────────────────
|
# ── Callbacks: смена статуса ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("st:"))
|
@bot.callback_query_handler(func=lambda c: c.data.startswith("st:"))
|
||||||
def cb_show_statuses(call):
|
def cb_show_statuses(call):
|
||||||
"""Показывает список статусов для выбора."""
|
task_number, group_id = _parse2(call.data[3:])
|
||||||
task_number = call.data[3:]
|
chat_id, msg_id = _current_msg(task_number, call)
|
||||||
markup = types.InlineKeyboardMarkup(row_width=1)
|
|
||||||
for sid, name in reference.STATUSES.items():
|
|
||||||
markup.add(types.InlineKeyboardButton(name, callback_data=f"ss:{task_number}:{sid}"))
|
|
||||||
markup.add(types.InlineKeyboardButton("✖ Отмена", callback_data="del"))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bot.send_message(
|
bot.edit_message_reply_markup(
|
||||||
call.message.chat.id,
|
chat_id, msg_id,
|
||||||
f"Заявка *\\#{task_number}* — выберите новый статус:",
|
reply_markup=_make_status_keyboard(task_number, group_id),
|
||||||
parse_mode="Markdown",
|
|
||||||
reply_markup=markup,
|
|
||||||
reply_to_message_id=call.message.message_id,
|
|
||||||
)
|
)
|
||||||
bot.answer_callback_query(call.id)
|
bot.answer_callback_query(call.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
bot.answer_callback_query(call.id, str(e), show_alert=True)
|
if _is_stale_error(e):
|
||||||
|
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
|
||||||
|
else:
|
||||||
|
bot.answer_callback_query(call.id, str(e), show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("ss:"))
|
@bot.callback_query_handler(func=lambda c: c.data.startswith("ss:"))
|
||||||
def cb_set_status(call):
|
def cb_set_status(call):
|
||||||
"""Применяет выбранный статус через API."""
|
# ss:{task_number}:{group_id}:{status_id}
|
||||||
_, task_number, status_id = call.data.split(":")
|
parts = call.data.split(":")
|
||||||
status_name = reference.STATUSES.get(int(status_id), f"#{status_id}")
|
task_number = parts[1]
|
||||||
|
group_id = int(parts[2])
|
||||||
|
status_id = int(parts[3])
|
||||||
|
status_name = reference.STATUSES.get(status_id, f"#{status_id}")
|
||||||
|
|
||||||
success = intradesk_api.change_status(int(task_number), int(status_id))
|
success = intradesk_api.change_status(int(task_number), status_id)
|
||||||
if success:
|
if success:
|
||||||
bot.answer_callback_query(call.id, f"✅ Статус → {status_name}")
|
bot.answer_callback_query(call.id, f"✅ Статус → {status_name}")
|
||||||
_delete_message(call.message.chat.id, call.message.message_id)
|
_restore_keyboard(call, task_number, group_id)
|
||||||
else:
|
else:
|
||||||
bot.answer_callback_query(call.id, "❌ Ошибка при изменении статуса", show_alert=True)
|
bot.answer_callback_query(call.id, "❌ Ошибка изменения статуса", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
# ── Callbacks: назначение исполнителя ─────────────────────────────────────────
|
# ── Callbacks: назначение исполнителя ─────────────────────────────────────────
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("ex:"))
|
@bot.callback_query_handler(func=lambda c: c.data.startswith("ex:"))
|
||||||
def cb_show_executors(call):
|
def cb_show_executors(call):
|
||||||
"""Показывает список сотрудников для назначения исполнителем."""
|
task_number, group_id = _parse2(call.data[3:])
|
||||||
task_number = call.data[3:]
|
chat_id, msg_id = _current_msg(task_number, call)
|
||||||
employees = database.get_all_ref("employees")
|
|
||||||
|
|
||||||
if not employees:
|
|
||||||
bot.answer_callback_query(
|
|
||||||
call.id,
|
|
||||||
"Список сотрудников не загружен. Обновите справочники.",
|
|
||||||
show_alert=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
markup = types.InlineKeyboardMarkup(row_width=1)
|
|
||||||
for emp in employees[:20]: # Telegram ограничивает кол-во кнопок
|
|
||||||
markup.add(types.InlineKeyboardButton(
|
|
||||||
emp["name"],
|
|
||||||
callback_data=f"se:{task_number}:{emp['id']}",
|
|
||||||
))
|
|
||||||
markup.add(types.InlineKeyboardButton("✖ Отмена", callback_data="del"))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bot.send_message(
|
bot.edit_message_reply_markup(
|
||||||
call.message.chat.id,
|
chat_id, msg_id,
|
||||||
f"Заявка *\\#{task_number}* — выберите исполнителя:",
|
reply_markup=_make_executor_keyboard(task_number, group_id),
|
||||||
parse_mode="Markdown",
|
|
||||||
reply_markup=markup,
|
|
||||||
reply_to_message_id=call.message.message_id,
|
|
||||||
)
|
)
|
||||||
bot.answer_callback_query(call.id)
|
bot.answer_callback_query(call.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
bot.answer_callback_query(call.id, str(e), show_alert=True)
|
if _is_stale_error(e):
|
||||||
|
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
|
||||||
|
else:
|
||||||
|
bot.answer_callback_query(call.id, str(e), show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("se:"))
|
@bot.callback_query_handler(func=lambda c: c.data.startswith("se:"))
|
||||||
def cb_set_executor(call):
|
def cb_set_executor(call):
|
||||||
"""Назначает выбранного сотрудника исполнителем через API."""
|
# se:{task_number}:{group_id}:{emp_id}
|
||||||
_, task_number, emp_id = call.data.split(":")
|
parts = call.data.split(":")
|
||||||
emp_name = database.resolve("employees", int(emp_id)) or f"#{emp_id}"
|
task_number = parts[1]
|
||||||
|
group_id = int(parts[2])
|
||||||
|
emp_id = int(parts[3])
|
||||||
|
emp_name = database.resolve("employees", emp_id) or f"ID {emp_id}"
|
||||||
|
|
||||||
success = intradesk_api.assign_executor(int(task_number), user_id=int(emp_id))
|
# Сохраняем текущую группу, меняем только исполнителя
|
||||||
|
success = intradesk_api.assign_executor(
|
||||||
|
int(task_number), user_id=emp_id, group_id=group_id or None
|
||||||
|
)
|
||||||
if success:
|
if success:
|
||||||
bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}")
|
bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}")
|
||||||
_delete_message(call.message.chat.id, call.message.message_id)
|
_restore_keyboard(call, task_number, group_id)
|
||||||
else:
|
else:
|
||||||
bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True)
|
bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
# ── Общий callback: удалить меню ──────────────────────────────────────────────
|
@bot.callback_query_handler(func=lambda c: c.data.startswith("su:"))
|
||||||
|
def cb_unset_executor(call):
|
||||||
|
"""Убрать конкретного исполнителя, оставить только группу."""
|
||||||
|
task_number, group_id = _parse2(call.data[3:])
|
||||||
|
if not group_id:
|
||||||
|
bot.answer_callback_query(call.id, "Группа не определена", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data == "del")
|
success = intradesk_api.assign_executor(int(task_number), group_id=group_id)
|
||||||
def cb_delete(call):
|
if success:
|
||||||
|
bot.answer_callback_query(call.id, "✅ Исполнитель снят, группа сохранена")
|
||||||
|
_restore_keyboard(call, task_number, group_id)
|
||||||
|
else:
|
||||||
|
bot.answer_callback_query(call.id, "❌ Ошибка", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Callbacks: "Взять в работу" / "Снять меня" ────────────────────────────────
|
||||||
|
|
||||||
|
@bot.callback_query_handler(func=lambda c: c.data.startswith("take:"))
|
||||||
|
def cb_take(call):
|
||||||
|
task_number = call.data[5:]
|
||||||
|
task_id = database.get_task_id_by_number(task_number)
|
||||||
|
if not task_id:
|
||||||
|
bot.answer_callback_query(call.id, "Заявка не найдена", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = call.from_user.id
|
||||||
|
user_name = (call.from_user.full_name or call.from_user.username or str(user_id)).strip()
|
||||||
|
|
||||||
|
database.add_responsible(task_id, user_id, user_name)
|
||||||
|
bot.answer_callback_query(call.id, f"🙋 Вы взяли заявку #{task_number} в работу")
|
||||||
|
_refresh_responsible(call, task_id, task_number)
|
||||||
|
|
||||||
|
|
||||||
|
@bot.callback_query_handler(func=lambda c: c.data.startswith("drop:"))
|
||||||
|
def cb_drop(call):
|
||||||
|
task_number = call.data[5:]
|
||||||
|
task_id = database.get_task_id_by_number(task_number)
|
||||||
|
if not task_id:
|
||||||
|
bot.answer_callback_query(call.id, "Заявка не найдена", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
database.remove_responsible(task_id, call.from_user.id)
|
||||||
|
bot.answer_callback_query(call.id, f"✅ Вы сняты с заявки #{task_number}")
|
||||||
|
_refresh_responsible(call, task_id, task_number)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Callback: назад (отмена выбора) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@bot.callback_query_handler(func=lambda c: c.data.startswith("back:"))
|
||||||
|
def cb_back(call):
|
||||||
|
task_number, group_id = _parse2(call.data[5:])
|
||||||
bot.answer_callback_query(call.id)
|
bot.answer_callback_query(call.id)
|
||||||
_delete_message(call.message.chat.id, call.message.message_id)
|
_restore_keyboard(call, task_number, group_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Вспомогательные функции ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse2(s: str) -> tuple[str, int]:
|
||||||
|
"""Парсит '{task_number}:{group_id}' → (task_number, group_id)."""
|
||||||
|
parts = s.split(":", 1)
|
||||||
|
return parts[0], int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _current_msg(task_number: str, call) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Возвращает (chat_id, message_id) актуального поста из БД.
|
||||||
|
Если поста нет в БД — использует данные из callback (может быть устаревшим).
|
||||||
|
"""
|
||||||
|
task_id = database.get_task_id_by_number(task_number)
|
||||||
|
if task_id:
|
||||||
|
data = database.get_message_data(task_id)
|
||||||
|
if data:
|
||||||
|
return config.CHANNEL_ID, data["message_id"]
|
||||||
|
return call.message.chat.id, call.message.message_id
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stale_error(e: Exception) -> bool:
|
||||||
|
"""Возвращает True если ошибка означает что сообщение уже удалено или не изменилось."""
|
||||||
|
s = str(e).lower()
|
||||||
|
return any(x in s for x in (
|
||||||
|
"message to edit not found",
|
||||||
|
"message is not modified",
|
||||||
|
"message can't be edited",
|
||||||
|
"bad request",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_keyboard(call, task_number: str, group_id: int):
|
||||||
|
"""Восстанавливает основные кнопки на актуальном посте задачи."""
|
||||||
|
chat_id, msg_id = _current_msg(task_number, call)
|
||||||
|
try:
|
||||||
|
bot.edit_message_reply_markup(
|
||||||
|
chat_id, msg_id,
|
||||||
|
reply_markup=make_task_buttons(task_number, group_id),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if not _is_stale_error(e):
|
||||||
|
log.warning(f"restore_keyboard failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_responsible(call, task_id: int, task_number: str):
|
||||||
|
"""Редактирует текст актуального поста из БД — обновляет список ответственных."""
|
||||||
|
data = database.get_message_data(task_id)
|
||||||
|
if not data or not data.get("base_text"):
|
||||||
|
return
|
||||||
|
|
||||||
|
responsible = database.get_responsible(task_id)
|
||||||
|
events_lines = database.get_stored_events(task_id)
|
||||||
|
full_text = _build_full_text(data["base_text"], responsible, events_lines, new_count=0)
|
||||||
|
markup = make_task_buttons(task_number, data.get("group_id") or 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
bot.edit_message_text(
|
||||||
|
full_text,
|
||||||
|
config.CHANNEL_ID,
|
||||||
|
data["message_id"],
|
||||||
|
parse_mode="Markdown",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
reply_markup=markup,
|
||||||
|
)
|
||||||
|
except ApiTelegramException as e:
|
||||||
|
if not _is_stale_error(e):
|
||||||
|
log.warning(f"_refresh_responsible failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _delete_message(chat_id, message_id):
|
def _delete_message(chat_id, message_id):
|
||||||
|
|
@ -179,8 +384,37 @@ def _delete_message(chat_id, message_id):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Гифка для заявок с ТО ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_gif_for_to(task_id: int):
|
||||||
|
"""
|
||||||
|
Отправляет гифку для заявки с ТО.
|
||||||
|
Предварительно удаляет предыдущую гифку если была.
|
||||||
|
Если GIF_URL пустой — ничего не делает.
|
||||||
|
"""
|
||||||
|
if not config.GIF_URL:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Удаляем старую гифку
|
||||||
|
old_gif_id = database.get_gif_message_id(task_id)
|
||||||
|
if old_gif_id:
|
||||||
|
_delete_message(config.CHANNEL_ID, old_gif_id)
|
||||||
|
database.delete_gif_message_id(task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = bot.send_animation(
|
||||||
|
config.CHANNEL_ID,
|
||||||
|
config.GIF_URL,
|
||||||
|
disable_notification=True,
|
||||||
|
)
|
||||||
|
database.save_gif_message_id(task_id, msg.message_id)
|
||||||
|
log.info(f"GIF sent for task {task_id}, message_id={msg.message_id}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to send GIF for task {task_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ── Запуск бота ───────────────────────────────────────────────────────────────
|
# ── Запуск бота ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def start_bot():
|
def start_bot():
|
||||||
log.info("Telegram bot started (polling)...")
|
log.info("Telegram bot started (polling)...")
|
||||||
bot.infinity_polling()
|
bot.infinity_polling(timeout=60)
|
||||||
|
|
|
||||||
67
config.py
67
config.py
|
|
@ -1,9 +1,62 @@
|
||||||
|
"""
|
||||||
|
Конфигурация IntradBot.
|
||||||
|
Все параметры читаются из переменных окружения (.env через docker-compose).
|
||||||
|
При старте проверяются обязательные переменные — если не заданы, падаем сразу.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
TOKEN = os.environ.get("TELEGRAM_TOKEN", "")
|
|
||||||
API_KEY = os.environ.get("INTRADESK_API_KEY", "")
|
def _require(name: str) -> str:
|
||||||
BASE_TASK_URL = os.environ.get("BASE_TASK_URL", "https://cleverparking.intradesk.ru/task/list/")
|
"""Читает переменную окружения. Если не задана — завершает процесс с понятной ошибкой."""
|
||||||
CHANNEL_ID = int(os.environ.get("CHANNEL_ID", "0"))
|
value = os.environ.get(name, "").strip()
|
||||||
DB_FILE = os.environ.get("DB_FILE", "/app/data/intradbot.db")
|
if not value:
|
||||||
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
|
print(f"[FATAL] Обязательная переменная окружения не задана: {name}", file=sys.stderr)
|
||||||
PORT = int(os.environ.get("PORT", "8080"))
|
sys.exit(1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional(name: str, default: str = "") -> str:
|
||||||
|
return os.environ.get(name, default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _int(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(os.environ.get(name, default))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# ── Обязательные ──────────────────────────────────────────────────────────────
|
||||||
|
TOKEN = _require("TELEGRAM_TOKEN")
|
||||||
|
API_KEY = _require("INTRADESK_API_KEY")
|
||||||
|
CHANNEL_ID = _int("CHANNEL_ID", 0)
|
||||||
|
|
||||||
|
# ── Необязательные ────────────────────────────────────────────────────────────
|
||||||
|
BASE_TASK_URL = _optional("BASE_TASK_URL", "https://cleverparking.intradesk.ru/task/list/")
|
||||||
|
DB_FILE = _optional("DB_FILE", "/app/data/intradbot.db")
|
||||||
|
WEBHOOK_SECRET = _optional("WEBHOOK_SECRET")
|
||||||
|
PORT = _int("PORT", 8080)
|
||||||
|
PUBLIC_URL = _optional("PUBLIC_URL")
|
||||||
|
GIF_URL = _optional("GIF_URL", "https://media.tenor.com/OzN-0kxqXAgAAAAi/cat.gif")
|
||||||
|
|
||||||
|
# Пароль для дашборда (Basic Auth). Пустая строка — дашборд открыт.
|
||||||
|
DASHBOARD_PASSWORD = _optional("DASHBOARD_PASSWORD")
|
||||||
|
|
||||||
|
# Секрет для подписи ссылок Mini App
|
||||||
|
MINIAPP_SECRET = _optional("MINIAPP_SECRET", WEBHOOK_SECRET or TOKEN[:20])
|
||||||
|
|
||||||
|
# ── Бизнес-параметры (вынесены из кода) ──────────────────────────────────────
|
||||||
|
# ID сотрудников доступных для назначения исполнителем (через запятую в .env)
|
||||||
|
# Пример: EXECUTOR_USER_IDS=2227907,631481,525111
|
||||||
|
_executor_ids_raw = _optional("EXECUTOR_USER_IDS", "2227907,631481,525111")
|
||||||
|
EXECUTOR_USER_IDS: list[int] = [
|
||||||
|
int(x.strip()) for x in _executor_ids_raw.split(",") if x.strip().isdigit()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Фильтр активных заявок для дашборда
|
||||||
|
# Пример: ACTIVE_TASKS_FILTER=(status ne 68045) and (executorgroup eq 243025)
|
||||||
|
ACTIVE_TASKS_FILTER = _optional(
|
||||||
|
"ACTIVE_TASKS_FILTER",
|
||||||
|
"(status ne 68045) and (executorgroup eq 243025)"
|
||||||
|
)
|
||||||
|
|
|
||||||
21978
data/bot.log
Normal file
21978
data/bot.log
Normal file
File diff suppressed because it is too large
Load diff
BIN
data/intradbot.db
Normal file
BIN
data/intradbot.db
Normal file
Binary file not shown.
216
database.py
216
database.py
|
|
@ -1,10 +1,25 @@
|
||||||
|
"""
|
||||||
|
Слой доступа к данным (SQLite).
|
||||||
|
|
||||||
|
Использует thread-local соединения — каждый поток получает своё соединение
|
||||||
|
и переиспользует его, вместо создания нового на каждый запрос.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
_local = threading.local()
|
||||||
|
|
||||||
def _connect():
|
|
||||||
conn = sqlite3.connect(config.DB_FILE)
|
def _connect() -> sqlite3.Connection:
|
||||||
conn.row_factory = sqlite3.Row
|
"""Возвращает соединение для текущего потока (создаёт при первом вызове)."""
|
||||||
|
conn = getattr(_local, "conn", None)
|
||||||
|
if conn is None:
|
||||||
|
conn = sqlite3.connect(config.DB_FILE, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
_local.conn = conn
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,10 +29,21 @@ def init_db():
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
task_id INTEGER PRIMARY KEY,
|
task_id INTEGER PRIMARY KEY,
|
||||||
message_id INTEGER NOT NULL,
|
message_id INTEGER NOT NULL,
|
||||||
task_number TEXT
|
task_number TEXT,
|
||||||
|
group_id INTEGER DEFAULT 0,
|
||||||
|
base_text TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
# Универсальная таблица справочников: entity = 'status'|'priority'|'service'|...
|
for col, dfn in [
|
||||||
|
("group_id", "INTEGER DEFAULT 0"),
|
||||||
|
("base_text", "TEXT"),
|
||||||
|
("events_json", "TEXT"),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
conn.execute(f"ALTER TABLE messages ADD COLUMN {col} {dfn}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS ref_data (
|
CREATE TABLE IF NOT EXISTS ref_data (
|
||||||
entity TEXT NOT NULL,
|
entity TEXT NOT NULL,
|
||||||
|
|
@ -26,59 +52,177 @@ def init_db():
|
||||||
PRIMARY KEY (entity, id)
|
PRIMARY KEY (entity, id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS responsible (
|
||||||
|
task_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
user_name TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (task_id, user_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS gif_messages (
|
||||||
|
task_id INTEGER PRIMARY KEY,
|
||||||
|
message_id INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
conn.execute("SELECT 1 FROM gif_messages LIMIT 1")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE gif_messages (
|
||||||
|
task_id INTEGER PRIMARY KEY,
|
||||||
|
message_id INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
# ── messages ──────────────────────────────────────────────────────────────────
|
# ── messages ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_message_id(task_id: int):
|
def get_message_id(task_id: int):
|
||||||
with _connect() as conn:
|
row = _connect().execute(
|
||||||
row = conn.execute(
|
"SELECT message_id FROM messages WHERE task_id = ?", (task_id,)
|
||||||
"SELECT message_id FROM messages WHERE task_id = ?", (task_id,)
|
).fetchone()
|
||||||
).fetchone()
|
|
||||||
return row["message_id"] if row else None
|
return row["message_id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
def save_message_id(task_id: int, message_id: int, task_number: str = None):
|
def get_message_data(task_id: int) -> dict | None:
|
||||||
with _connect() as conn:
|
row = _connect().execute(
|
||||||
conn.execute(
|
"SELECT message_id, task_number, group_id, base_text FROM messages WHERE task_id = ?",
|
||||||
"INSERT OR REPLACE INTO messages (task_id, message_id, task_number) VALUES (?, ?, ?)",
|
(task_id,),
|
||||||
(task_id, message_id, task_number),
|
).fetchone()
|
||||||
)
|
return dict(row) if row else None
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
def get_task_id_by_number(task_number: str) -> int | None:
|
||||||
|
row = _connect().execute(
|
||||||
|
"SELECT task_id FROM messages WHERE task_number = ?", (str(task_number),)
|
||||||
|
).fetchone()
|
||||||
|
return row["task_id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_message_id(
|
||||||
|
task_id: int,
|
||||||
|
message_id: int,
|
||||||
|
task_number: str = None,
|
||||||
|
group_id: int = 0,
|
||||||
|
base_text: str = None,
|
||||||
|
):
|
||||||
|
conn = _connect()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT events_json FROM messages WHERE task_id = ?", (task_id,)
|
||||||
|
).fetchone()
|
||||||
|
existing_events = row["events_json"] if row else None
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT OR REPLACE INTO messages
|
||||||
|
(task_id, message_id, task_number, group_id, base_text, events_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
|
(task_id, message_id, task_number, group_id or 0, base_text, existing_events),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_stored_events(task_id: int) -> list[str]:
|
||||||
|
row = _connect().execute(
|
||||||
|
"SELECT events_json FROM messages WHERE task_id = ?", (task_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row or not row["events_json"]:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(row["events_json"])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_stored_events(task_id: int, events: list[str]):
|
||||||
|
conn = _connect()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE messages SET events_json = ? WHERE task_id = ?",
|
||||||
|
(json.dumps(events, ensure_ascii=False), task_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
# ── ref_data ──────────────────────────────────────────────────────────────────
|
# ── ref_data ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def save_ref_data(entity: str, items: list[tuple[int, str]]):
|
def save_ref_data(entity: str, items: list[tuple[int, str]]):
|
||||||
"""Перезаписывает весь справочник entity списком пар (id, name)."""
|
conn = _connect()
|
||||||
with _connect() as conn:
|
conn.executemany(
|
||||||
conn.execute("DELETE FROM ref_data WHERE entity = ?", (entity,))
|
"INSERT OR REPLACE INTO ref_data (entity, id, name) VALUES (?, ?, ?)",
|
||||||
conn.executemany(
|
[(entity, item_id, name) for item_id, name in items],
|
||||||
"INSERT INTO ref_data (entity, id, name) VALUES (?, ?, ?)",
|
)
|
||||||
[(entity, item_id, name) for item_id, name in items],
|
conn.commit()
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def resolve(entity: str, item_id: int | None) -> str | None:
|
def resolve(entity: str, item_id: int | None) -> str | None:
|
||||||
"""Возвращает name по entity + id. None если не найдено."""
|
|
||||||
if item_id is None:
|
if item_id is None:
|
||||||
return None
|
return None
|
||||||
with _connect() as conn:
|
row = _connect().execute(
|
||||||
row = conn.execute(
|
"SELECT name FROM ref_data WHERE entity = ? AND id = ?",
|
||||||
"SELECT name FROM ref_data WHERE entity = ? AND id = ?",
|
(entity, item_id),
|
||||||
(entity, item_id),
|
).fetchone()
|
||||||
).fetchone()
|
|
||||||
return row["name"] if row else None
|
return row["name"] if row else None
|
||||||
|
|
||||||
|
|
||||||
def get_all_ref(entity: str) -> list[dict]:
|
def get_all_ref(entity: str) -> list[dict]:
|
||||||
"""Возвращает все записи справочника как список {id, name}."""
|
rows = _connect().execute(
|
||||||
with _connect() as conn:
|
"SELECT id, name FROM ref_data WHERE entity = ? ORDER BY name",
|
||||||
rows = conn.execute(
|
(entity,),
|
||||||
"SELECT id, name FROM ref_data WHERE entity = ? ORDER BY name",
|
).fetchall()
|
||||||
(entity,),
|
|
||||||
).fetchall()
|
|
||||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── responsible ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_responsible(task_id: int, user_id: int, user_name: str):
|
||||||
|
conn = _connect()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO responsible (task_id, user_id, user_name) VALUES (?, ?, ?)",
|
||||||
|
(task_id, user_id, user_name),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_responsible(task_id: int, user_id: int):
|
||||||
|
conn = _connect()
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM responsible WHERE task_id = ? AND user_id = ?",
|
||||||
|
(task_id, user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_responsible(task_id: int) -> list[dict]:
|
||||||
|
rows = _connect().execute(
|
||||||
|
"SELECT user_id, user_name FROM responsible WHERE task_id = ? ORDER BY rowid",
|
||||||
|
(task_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [{"user_id": r["user_id"], "user_name": r["user_name"]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── gif_messages ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_gif_message_id(task_id: int) -> int | None:
|
||||||
|
row = _connect().execute(
|
||||||
|
"SELECT message_id FROM gif_messages WHERE task_id = ?", (task_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row["message_id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_gif_message_id(task_id: int, message_id: int):
|
||||||
|
conn = _connect()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO gif_messages (task_id, message_id) VALUES (?, ?)",
|
||||||
|
(task_id, message_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_gif_message_id(task_id: int):
|
||||||
|
conn = _connect()
|
||||||
|
conn.execute("DELETE FROM gif_messages WHERE task_id = ?", (task_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data # БД и логи хранятся вне контейнера
|
- ./data:/app/data
|
||||||
|
- ./ssl:/app/ssl:ro # ← реальные файлы без симлинков
|
||||||
16
intradbot.py
16
intradbot.py
|
|
@ -1,3 +1,14 @@
|
||||||
|
"""
|
||||||
|
Точка входа IntradBot.
|
||||||
|
|
||||||
|
Запускает три компонента:
|
||||||
|
1. Telegram бот (polling) — в отдельном потоке
|
||||||
|
2. Flask веб-сервер (dev) или Gunicorn (prod) — в отдельном потоке
|
||||||
|
3. Шедулер обновления справочников — в главном потоке
|
||||||
|
|
||||||
|
В продакшене (Docker) HTTP обслуживает Gunicorn напрямую через web.server:create_app,
|
||||||
|
а этот процесс запускает только бота и шедулер.
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -31,9 +42,10 @@ def main():
|
||||||
reference.seed_static()
|
reference.seed_static()
|
||||||
reference.load_all()
|
reference.load_all()
|
||||||
|
|
||||||
threading.Thread(target=start_bot, daemon=True).start()
|
threading.Thread(target=start_bot, daemon=True, name="telegram-bot").start()
|
||||||
threading.Thread(target=start_server, daemon=True).start()
|
threading.Thread(target=start_server, daemon=True, name="web-server").start()
|
||||||
|
|
||||||
|
# Обновляем справочники каждый понедельник (новые сотрудники, группы и т.д.)
|
||||||
schedule.every().monday.at("10:00").do(reference.load_all)
|
schedule.every().monday.at("10:00").do(reference.load_all)
|
||||||
|
|
||||||
log.info("IntradBot is running. Waiting for webhooks...")
|
log.info("IntradBot is running. Waiting for webhooks...")
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,25 @@
|
||||||
|
"""
|
||||||
|
Парсинг и форматирование данных из вебхуков и API Intradesk.
|
||||||
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import config
|
|
||||||
import database
|
import database
|
||||||
from intradesk import reference
|
from intradesk import reference
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Маппинг blockname → entity в БД справочников
|
# ── Маппинги ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ADDFIELD_BOOL_LABELS: dict[str, str] = {
|
||||||
|
"addfield_677b45atoBoolean": "ТО",
|
||||||
|
"addfield_ea5d305sozvon_pered_vyezdomBoolean": "Созвон перед выездом",
|
||||||
|
"addfield_2684bdczamena_oborudovaniiaBoolean": "Замена оборудования",
|
||||||
|
}
|
||||||
|
|
||||||
BLOCK_TO_ENTITY: dict[str, str] = {
|
BLOCK_TO_ENTITY: dict[str, str] = {
|
||||||
"status": "statuses",
|
"status": "statuses",
|
||||||
"priority": "priorities",
|
"priority": "priorities",
|
||||||
|
|
@ -20,8 +32,7 @@ BLOCK_TO_ENTITY: dict[str, str] = {
|
||||||
"sla": "sla",
|
"sla": "sla",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Человекочитаемые названия полей
|
FIELD_LABELS: dict[str, str] = {
|
||||||
FIELD_LABELS = {
|
|
||||||
"status": "Статус",
|
"status": "Статус",
|
||||||
"priority": "Приоритет",
|
"priority": "Приоритет",
|
||||||
"executor": "Исполнитель",
|
"executor": "Исполнитель",
|
||||||
|
|
@ -38,16 +49,51 @@ FIELD_LABELS = {
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Поля, изменение которых не выводится в уведомлении
|
IGNORED_FIELDS: set[str] = {
|
||||||
IGNORED_FIELDS = {
|
|
||||||
"lifetime", "updatedat", "updatedby", "updatedchannel",
|
"lifetime", "updatedat", "updatedby", "updatedchannel",
|
||||||
"reactiondatefact", "resolutiondatefact", "resolutionleftminutes",
|
"reactiondatefact", "resolutiondatefact", "resolutionleftminutes",
|
||||||
"reactiondateplan", "ismassincidentactive",
|
"reactiondateplan", "ismassincidentactive",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_QUOTE_RE = re.compile(
|
||||||
|
r'\[quote[^\]]*\].*?\[/quote\]'
|
||||||
|
r'|\{quote[^\}]*\}.*?\{/quote\}'
|
||||||
|
r'|<blockquote[^>]*>.*?</blockquote>'
|
||||||
|
r'|<quote[^>]*>.*?</quote>',
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FILE_EXT_RE = re.compile(r'\.([a-zA-Z0-9]{1,10})$')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Типы событий ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedEvent:
|
||||||
|
"""Нормализованное событие из истории заявки."""
|
||||||
|
kind: Literal["comment", "change"]
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Утилиты ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _shorten_name(name: str | None) -> str | None:
|
||||||
|
"""'Иванов Иван Петрович' → 'Иванов И.'"""
|
||||||
|
if not name:
|
||||||
|
return name
|
||||||
|
parts = name.strip().split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return f"{parts[0]} {parts[1][0]}."
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_quote(text: str) -> tuple[str, bool]:
|
||||||
|
"""Убирает блоки цитирования. Возвращает (cleaned_text, had_quote)."""
|
||||||
|
cleaned, n = _QUOTE_RE.subn("", text)
|
||||||
|
return cleaned.strip(), n > 0
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_field(raw):
|
def _parse_json_field(raw):
|
||||||
"""Парсит JSON-строку или возвращает как есть."""
|
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(raw, (dict, list)):
|
if isinstance(raw, (dict, list)):
|
||||||
|
|
@ -61,7 +107,6 @@ def _parse_json_field(raw):
|
||||||
|
|
||||||
|
|
||||||
def _scalar(raw):
|
def _scalar(raw):
|
||||||
"""Возвращает скалярное значение поля Fields (Value или Id)."""
|
|
||||||
parsed = _parse_json_field(raw)
|
parsed = _parse_json_field(raw)
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
return parsed.get("Value") or parsed.get("Id")
|
return parsed.get("Value") or parsed.get("Id")
|
||||||
|
|
@ -69,139 +114,170 @@ def _scalar(raw):
|
||||||
|
|
||||||
|
|
||||||
def _extract_name(v: dict | None, blockname: str = "") -> str | None:
|
def _extract_name(v: dict | None, blockname: str = "") -> str | None:
|
||||||
"""
|
|
||||||
Достаёт человекочитаемое имя из объекта valuenew/valueold.
|
|
||||||
|
|
||||||
Обрабатывает три варианта поля name:
|
|
||||||
1. строка: {"id": 8452, "name": "Иванов"}
|
|
||||||
2. объект {ru, en}: {"id": 96, "name": {"ru": "Выполнена", "en": null}}
|
|
||||||
3. отсутствует/null: {"id": 68045, "name": null} → резольвинг через БД
|
|
||||||
"""
|
|
||||||
if not v:
|
if not v:
|
||||||
return None
|
return None
|
||||||
|
raw_name = v.get("currentname") or v.get("name") or v.get("Name")
|
||||||
raw_name = v.get("currentname") or v.get("name")
|
|
||||||
|
|
||||||
# name — объект {"ru": ..., "en": ...}
|
|
||||||
if isinstance(raw_name, dict):
|
if isinstance(raw_name, dict):
|
||||||
raw_name = raw_name.get("ru") or raw_name.get("en") or None
|
raw_name = raw_name.get("ru") or raw_name.get("en") or None
|
||||||
|
|
||||||
if raw_name:
|
if raw_name:
|
||||||
return str(raw_name).strip() or None
|
name_str = str(raw_name).strip() or None
|
||||||
|
if name_str and blockname in ("executor", "initiator"):
|
||||||
# name отсутствует или null — резольвинг через БД/статик
|
return _shorten_name(name_str)
|
||||||
item_id = v.get("id")
|
return name_str
|
||||||
|
item_id = v.get("id") or v.get("Id")
|
||||||
entity = BLOCK_TO_ENTITY.get(blockname)
|
entity = BLOCK_TO_ENTITY.get(blockname)
|
||||||
if item_id and entity:
|
if item_id and entity:
|
||||||
return reference.resolve(entity, item_id) or str(item_id)
|
resolved = reference.resolve(entity, item_id) or str(item_id)
|
||||||
|
if blockname in ("executor", "initiator"):
|
||||||
|
return _shorten_name(resolved)
|
||||||
|
return resolved
|
||||||
return str(item_id) if item_id else None
|
return str(item_id) if item_id else None
|
||||||
|
|
||||||
|
|
||||||
def get_executor_group(fields: dict) -> str | None:
|
# ── Ядро: разбор одного события ───────────────────────────────────────────────
|
||||||
"""Группа исполнителей (Type=10 в taskusergroups)."""
|
|
||||||
raw = _parse_json_field(fields.get("taskusergroups", {}))
|
def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
if not isinstance(raw, dict):
|
"""
|
||||||
|
Разбирает одно сырое событие из lifetime в нормализованный ParsedEvent.
|
||||||
|
|
||||||
|
Используется как в parse_lifetime() (вебхук), так и в format_lifetime_api_events()
|
||||||
|
(прямой API). Единственный источник правды для логики разбора событий.
|
||||||
|
"""
|
||||||
|
etype = event.get("type")
|
||||||
|
blockname = event.get("blockname", "")
|
||||||
|
short_user = _shorten_name(username) or "Неизвестный"
|
||||||
|
|
||||||
|
# ── Комментарии ──────────────────────────────────────────────────────────
|
||||||
|
if etype in (50, 55):
|
||||||
|
text = event.get("stringvalue") or ""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
text, had_quote = _strip_quote(text)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
prefix = "Ответ на прошлое: " if had_quote else ""
|
||||||
|
lock = " 🔒" if etype == 55 else ""
|
||||||
|
return ParsedEvent("comment", f"*{short_user}*{lock}: {prefix}{text}")
|
||||||
|
|
||||||
|
if blockname in IGNORED_FIELDS:
|
||||||
return None
|
return None
|
||||||
for group in raw.get("TaskUserGroups", []):
|
|
||||||
if group.get("Type") == 10:
|
# ── Доп. поля Boolean (ТО, Созвон и т.д.) ────────────────────────────────
|
||||||
return group.get("Name", "").strip() or None
|
addfield = event.get("additionalfieldvalue")
|
||||||
|
if addfield is None and blockname.startswith("addfield_"):
|
||||||
|
str_val_add = event.get("stringvalue")
|
||||||
|
if str_val_add is not None:
|
||||||
|
addfield = {"value": str_val_add}
|
||||||
|
|
||||||
|
if addfield is not None and blockname.startswith("addfield_"):
|
||||||
|
bool_label = _ADDFIELD_BOOL_LABELS.get(blockname)
|
||||||
|
if bool_label:
|
||||||
|
raw_new = addfield.get("value") if isinstance(addfield, dict) else addfield
|
||||||
|
raw_old = addfield.get("oldvalue") if isinstance(addfield, dict) else None
|
||||||
|
_bool_str = lambda v: "*да*" if str(v).lower() == "true" else "нет"
|
||||||
|
if raw_old is not None:
|
||||||
|
return ParsedEvent("change", f"• {bool_label}: {_bool_str(raw_old)} → {_bool_str(raw_new)}")
|
||||||
|
return ParsedEvent("change", f"• {bool_label}: {_bool_str(raw_new)}")
|
||||||
|
return None # Неизвестное доп. поле — пропускаем
|
||||||
|
|
||||||
|
label = FIELD_LABELS.get(blockname, reference.event_name(etype))
|
||||||
|
|
||||||
|
# ── Изменения с entityvalue / entitylanguagevalue ─────────────────────────
|
||||||
|
entity_data = event.get("entityvalue") or event.get("entitylanguagevalue")
|
||||||
|
if entity_data:
|
||||||
|
val_new = entity_data.get("valuenew") or {}
|
||||||
|
val_old = entity_data.get("valueold")
|
||||||
|
new_str = _extract_name(val_new, blockname)
|
||||||
|
old_str = _extract_name(val_old, blockname) if val_old else None
|
||||||
|
if not new_str and not old_str:
|
||||||
|
return None
|
||||||
|
if new_str and old_str:
|
||||||
|
return ParsedEvent("change", f"• {label}: {old_str} → *{new_str}*")
|
||||||
|
if new_str:
|
||||||
|
return ParsedEvent("change", f"• {label}: *{new_str}*")
|
||||||
|
return ParsedEvent("change", f"• {label}: {old_str} → *снят*")
|
||||||
|
|
||||||
|
# ── Строковые изменения ───────────────────────────────────────────────────
|
||||||
|
str_val = event.get("stringvalue")
|
||||||
|
if str_val:
|
||||||
|
if blockname in ("file", "files", "taskfiles", "attachment", "attachments"):
|
||||||
|
attach = _parse_json_field(str_val)
|
||||||
|
if isinstance(attach, dict):
|
||||||
|
fname = attach.get("Name", "")
|
||||||
|
uploader = _shorten_name(attach.get("UploadedBy", "").strip() or None) or "?"
|
||||||
|
return ParsedEvent("change", f"• 📎 {uploader} прикрепил: {fname}")
|
||||||
|
m = _FILE_EXT_RE.search(str_val.strip())
|
||||||
|
ext = m.group(1).upper() if m else "файл"
|
||||||
|
return ParsedEvent("change", f"• 📎 Добавлен файл .{ext}")
|
||||||
|
truncated = str_val[:80] + ("…" if len(str_val) > 80 else "")
|
||||||
|
return ParsedEvent("change", f"• {label}: *{truncated}*")
|
||||||
|
|
||||||
|
# ── Событие без значения ──────────────────────────────────────────────────
|
||||||
|
if etype not in (185, 250):
|
||||||
|
return ParsedEvent("change", f"• {label}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_initiator(fields: dict) -> str | None:
|
# ── Публичные функции ─────────────────────────────────────────────────────────
|
||||||
"""Инициатор заявки (Type=10 в taskusers)."""
|
|
||||||
raw = _parse_json_field(fields.get("taskusers", {}))
|
def get_description_event(fields: dict) -> str | None:
|
||||||
if not isinstance(raw, dict):
|
description = _scalar(fields.get("description"))
|
||||||
|
if not description:
|
||||||
return None
|
return None
|
||||||
for user in raw.get("TaskUsers", []):
|
description = re.sub(r'<[^>]+>', ' ', description).strip()
|
||||||
if user.get("Type") == 10:
|
description = re.sub(r'\s+', ' ', description).strip()
|
||||||
return user.get("Name", "").strip() or None
|
if not description:
|
||||||
return None
|
return None
|
||||||
|
truncated = description[:100] + ('…' if len(description) > 100 else '')
|
||||||
|
return f"📝 {truncated}"
|
||||||
|
|
||||||
|
|
||||||
def is_final(fields: dict) -> bool:
|
def parse_lifetime(fields: dict, max_events: int = 5) -> tuple[list[str], list[str]]:
|
||||||
"""Возвращает True если статус задачи финальный (закрыта)."""
|
|
||||||
raw = _parse_json_field(fields.get("status", {}))
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return bool(raw.get("IsFinal", False))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def parse_lifetime(fields: dict) -> tuple[list[str], list[str]]:
|
|
||||||
"""
|
"""
|
||||||
Разбирает lifetime-поле вебхука.
|
Разбирает lifetime-поле вебхука (вложенная структура).
|
||||||
|
Возвращает (changes, comments) — последние max_events событий.
|
||||||
Возвращает:
|
|
||||||
changes — строки вида «• Поле: было → стало»
|
|
||||||
comments — строки вида «*Автор*: текст»
|
|
||||||
"""
|
"""
|
||||||
changes: list[str] = []
|
all_events: list[ParsedEvent] = []
|
||||||
comments: list[str] = []
|
|
||||||
|
|
||||||
lifetime = _parse_json_field(fields.get("lifetime", "{}"))
|
lifetime = _parse_json_field(fields.get("lifetime", "{}"))
|
||||||
if not isinstance(lifetime, dict):
|
if not isinstance(lifetime, dict):
|
||||||
return changes, comments
|
return [], []
|
||||||
|
|
||||||
for entry in lifetime.get("Data", []):
|
for entry in lifetime.get("Data", []):
|
||||||
|
if entry.get("lifetimetype") == 10:
|
||||||
|
continue
|
||||||
username = entry.get("username", "Неизвестный")
|
username = entry.get("username", "Неизвестный")
|
||||||
|
|
||||||
for event in entry.get("events", {}).get("Data", []):
|
for event in entry.get("events", {}).get("Data", []):
|
||||||
etype = event.get("type")
|
parsed = _parse_single_event(event, username)
|
||||||
blockname = event.get("blockname", "")
|
if parsed:
|
||||||
|
all_events.append(parsed)
|
||||||
# ── Комментарии ────────────────────────────────────────────────
|
|
||||||
if etype == 50:
|
|
||||||
text = event.get("stringvalue")
|
|
||||||
if text:
|
|
||||||
comments.append(f"*{username}*: {text}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Приватный комментарий (необязательно показывать, но пусть будет)
|
|
||||||
if etype == 55:
|
|
||||||
text = event.get("stringvalue")
|
|
||||||
if text:
|
|
||||||
comments.append(f"*{username}* 🔒: {text}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if blockname in IGNORED_FIELDS:
|
|
||||||
continue
|
|
||||||
|
|
||||||
label = FIELD_LABELS.get(blockname, reference.event_name(etype))
|
|
||||||
|
|
||||||
# ── Изменения с entityvalue / entitylanguagevalue ──────────────
|
|
||||||
entity_data = event.get("entityvalue") or event.get("entitylanguagevalue")
|
|
||||||
if entity_data:
|
|
||||||
val_new = entity_data.get("valuenew") or {}
|
|
||||||
val_old = entity_data.get("valueold")
|
|
||||||
|
|
||||||
new_str = _extract_name(val_new, blockname)
|
|
||||||
old_str = _extract_name(val_old, blockname) if val_old else None
|
|
||||||
|
|
||||||
if not new_str:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if old_str:
|
|
||||||
changes.append(f"• {label}: {old_str} → *{new_str}*")
|
|
||||||
else:
|
|
||||||
changes.append(f"• {label}: *{new_str}*")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ── Строковые изменения (название, описание) ───────────────────
|
|
||||||
str_val = event.get("stringvalue")
|
|
||||||
if str_val:
|
|
||||||
changes.append(f"• {label}: *{str_val[:80]}{'…' if len(str_val) > 80 else ''}*")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ── Событие без значения (теги, файлы, эскалация) ─────────────
|
|
||||||
if etype not in (185, 250): # пропускаем служебные
|
|
||||||
changes.append(f"• {label}")
|
|
||||||
|
|
||||||
|
recent = all_events[-max_events:] if max_events and len(all_events) > max_events else all_events
|
||||||
|
changes = [e.text for e in recent if e.kind == "change"]
|
||||||
|
comments = [e.text for e in recent if e.kind == "comment"]
|
||||||
return changes, comments
|
return changes, comments
|
||||||
|
|
||||||
|
|
||||||
|
def format_lifetime_api_events(events_list: list[dict], max_events: int = 5) -> str:
|
||||||
|
"""
|
||||||
|
Форматирует события из прямого API lifetime (плоский список).
|
||||||
|
events_list — результат intradesk_api.get_task_lifetime().
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
for event in events_list[:max_events]:
|
||||||
|
username = event.get("username", "")
|
||||||
|
parsed = _parse_single_event(event, username)
|
||||||
|
if parsed:
|
||||||
|
# Для API-формата обрезаем комментарии до 100 символов
|
||||||
|
text = parsed.text
|
||||||
|
if parsed.kind == "comment" and len(text) > 110:
|
||||||
|
text = text[:110] + "…"
|
||||||
|
lines.append(text)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_field(fields: dict, field: str, entity: str) -> str | None:
|
def _resolve_field(fields: dict, field: str, entity: str) -> str | None:
|
||||||
"""Резолвит ID поля верхнего уровня через справочники."""
|
|
||||||
raw = _parse_json_field(fields.get(field, {}))
|
raw = _parse_json_field(fields.get(field, {}))
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return None
|
return None
|
||||||
|
|
@ -211,60 +287,112 @@ def _resolve_field(fields: dict, field: str, entity: str) -> str | None:
|
||||||
return reference.resolve(entity, item_id) or str(item_id)
|
return reference.resolve(entity, item_id) or str(item_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_object_name(fields: dict) -> str | None:
|
||||||
|
raw = _parse_json_field(fields.get("taskusergroups", {}))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
for group in raw.get("TaskUserGroups", []):
|
||||||
|
if group.get("Type") == 50:
|
||||||
|
return group.get("Name", "").strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_executor_group(fields: dict) -> str | None:
|
||||||
|
raw = _parse_json_field(fields.get("taskusergroups", {}))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
for group in raw.get("TaskUserGroups", []):
|
||||||
|
if group.get("Type") == 10:
|
||||||
|
return group.get("Name", "").strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_executor_group_id(fields: dict) -> int:
|
||||||
|
raw = _parse_json_field(fields.get("taskusergroups", {}))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return 0
|
||||||
|
for group in raw.get("TaskUserGroups", []):
|
||||||
|
if group.get("Type") == 10:
|
||||||
|
return int(group.get("Id", 0))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_initiator(fields: dict) -> str | None:
|
||||||
|
raw = _parse_json_field(fields.get("taskusers", {}))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
for user in raw.get("TaskUsers", []):
|
||||||
|
if user.get("Type") == 10:
|
||||||
|
return _shorten_name((user.get("Name") or "").strip() or None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_executor(fields: dict) -> str | None:
|
||||||
|
raw = _parse_json_field(fields.get("taskusers", {}))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
for user in raw.get("TaskUsers", []):
|
||||||
|
if user.get("Type") == 30:
|
||||||
|
return _shorten_name((user.get("Name") or "").strip() or None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_final(fields: dict) -> bool:
|
||||||
|
raw = _parse_json_field(fields.get("status", {}))
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return bool(raw.get("IsFinal", False))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_to(fields: dict) -> bool:
|
||||||
|
raw = _parse_json_field(fields.get("addfield_677b45atoBoolean"))
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
val = raw.get("Value")
|
||||||
|
return str(val).lower() == "true" if val is not None else False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_executors(fields: dict) -> list[str]:
|
||||||
|
result: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
raw_users = _parse_json_field(fields.get("taskusers", {}))
|
||||||
|
if isinstance(raw_users, dict):
|
||||||
|
for user in raw_users.get("TaskUsers", []):
|
||||||
|
if user.get("Type") == 30:
|
||||||
|
name = _shorten_name((user.get("Name") or "").strip() or None)
|
||||||
|
if name and name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
result.append(name)
|
||||||
|
group = get_executor_group(fields)
|
||||||
|
if group and group not in seen:
|
||||||
|
result.append(group)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def format_notification(payload: dict) -> str:
|
def format_notification(payload: dict) -> str:
|
||||||
"""Полная карточка задачи для основного поста в канале."""
|
"""Полная карточка задачи для основного поста в канале."""
|
||||||
op_type = payload.get("Event", {}).get("OperationType", "")
|
op_type = payload.get("Event", {}).get("OperationType", "")
|
||||||
fields = payload.get("Fields", {})
|
fields = payload.get("Fields", {})
|
||||||
|
number = fields.get("Number", "?")
|
||||||
number = fields.get("Number", "?")
|
name = _scalar(fields.get("name")) or "—"
|
||||||
name = _scalar(fields.get("name")) or "—"
|
applicant = get_initiator(fields)
|
||||||
executor_grp = get_executor_group(fields)
|
executors = get_all_executors(fields)
|
||||||
initiator = get_initiator(fields)
|
obj_name = get_object_name(fields)
|
||||||
|
to_flag = is_to(fields)
|
||||||
status = _resolve_field(fields, "status", "statuses")
|
status = _resolve_field(fields, "status", "statuses")
|
||||||
priority = _resolve_field(fields, "priority", "priorities")
|
|
||||||
service = _resolve_field(fields, "service", "services")
|
|
||||||
tasktype = _resolve_field(fields, "tasktype", "tasktypes")
|
|
||||||
|
|
||||||
changes, comments = parse_lifetime(fields)
|
|
||||||
|
|
||||||
header = f"🆕 *Новая заявка \\#{number}*" if op_type == "Created" \
|
|
||||||
else f"🔄 *Заявка \\#{number} обновлена*"
|
|
||||||
|
|
||||||
|
header = f"🆕 *Новая #{number}*" if op_type == "Created" \
|
||||||
|
else f"🔄 *#{number} обновлена*"
|
||||||
lines = [header, name, ""]
|
lines = [header, name, ""]
|
||||||
|
if to_flag: lines.append("🔧 *ТО*")
|
||||||
if status:
|
if status: lines.append(f"📌 Статус: {status}")
|
||||||
lines.append(f"📌 Статус: {status}")
|
if obj_name: lines.append(f"🏢 Объект: {obj_name}")
|
||||||
if priority:
|
if applicant: lines.append(f"👤 Автор: {applicant}")
|
||||||
lines.append(f"🔺 Приоритет: {priority}")
|
if executors: lines.append(f"👔 Исполнитель: {', '.join(executors)}")
|
||||||
if service:
|
|
||||||
lines.append(f"🗂 Услуга: {service}")
|
|
||||||
if tasktype:
|
|
||||||
lines.append(f"📋 Тип: {tasktype}")
|
|
||||||
if executor_grp:
|
|
||||||
lines.append(f"👥 Группа: {executor_grp}")
|
|
||||||
if initiator:
|
|
||||||
lines.append(f"👤 Инициатор: {initiator}")
|
|
||||||
if any([status, priority, service, tasktype, executor_grp, initiator]):
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if changes:
|
|
||||||
lines.extend(changes)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if comments:
|
|
||||||
lines.append("💬 *Комментарии:*")
|
|
||||||
lines.extend(comments)
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
task_url = config.BASE_TASK_URL + str(number)
|
|
||||||
lines.append(f"[Открыть заявку]({task_url})")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def format_update_summary(changes: list[str], comments: list[str]) -> str | None:
|
def format_update_summary(changes: list[str], comments: list[str]) -> str | None:
|
||||||
"""Короткий reply-пост при обновлении (триггерит пуш подписчикам канала)."""
|
|
||||||
if not changes and not comments:
|
if not changes and not comments:
|
||||||
return None
|
return None
|
||||||
lines = ["🔄 *Изменено:*"]
|
lines = ["🔄 *Изменено:*"]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import database
|
||||||
import events
|
import events
|
||||||
from bot import telegram as bot
|
from bot import telegram as tg
|
||||||
from intradesk import formatter
|
from intradesk import formatter
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -24,7 +25,20 @@ def handle_webhook(payload: dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
changes, comments = formatter.parse_lifetime(fields)
|
changes, comments = formatter.parse_lifetime(fields)
|
||||||
full_text = formatter.format_notification(payload)
|
base_text = formatter.format_notification(payload)
|
||||||
|
group_id = formatter.get_executor_group_id(fields)
|
||||||
|
closed = formatter.is_final(fields)
|
||||||
|
is_to_now = formatter.is_to(fields)
|
||||||
|
|
||||||
|
# Формируем плоский список новых строк событий для накопления в посте
|
||||||
|
new_event_lines = changes + [f"💬 {c}" for c in comments]
|
||||||
|
|
||||||
|
# Если заявка новая в БД — добавляем описание первым событием
|
||||||
|
is_new_task = not database.get_message_data(task_id)
|
||||||
|
if is_new_task:
|
||||||
|
desc_event = formatter.get_description_event(fields)
|
||||||
|
if desc_event:
|
||||||
|
new_event_lines = [desc_event] + new_event_lines
|
||||||
|
|
||||||
events.broadcast({
|
events.broadcast({
|
||||||
"type": "webhook",
|
"type": "webhook",
|
||||||
|
|
@ -37,16 +51,18 @@ def handle_webhook(payload: dict):
|
||||||
"changes": changes,
|
"changes": changes,
|
||||||
"comments": comments,
|
"comments": comments,
|
||||||
},
|
},
|
||||||
"formatted": full_text,
|
"formatted": base_text,
|
||||||
})
|
})
|
||||||
|
|
||||||
closed = formatter.is_final(fields)
|
tg.recreate_post(
|
||||||
|
task_id, str(number), base_text, group_id,
|
||||||
|
closed=closed, new_event_lines=new_event_lines,
|
||||||
|
)
|
||||||
|
|
||||||
if op_type == "Created":
|
# ── Гифка для ТО ──────────────────────────────────────────────────────
|
||||||
bot.send_or_edit(task_id, number, full_text, closed=closed)
|
# Отправляем (и удаляем предыдущую) при любом вебхуке если ТО = да
|
||||||
else:
|
if is_to_now:
|
||||||
summary = formatter.format_update_summary(changes, comments)
|
tg.send_gif_for_to(task_id)
|
||||||
bot.send_or_edit(task_id, number, full_text, summary, closed=closed)
|
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Error while handling webhook")
|
log.exception("Error while handling webhook")
|
||||||
|
|
|
||||||
|
|
@ -101,88 +101,22 @@ EVENT_TYPES: dict[int, str] = {
|
||||||
400: "Эскалация заявки",
|
400: "Эскалация заявки",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── API-справочники (загружаются из Intradesk) ─────────────────────────────────
|
# ── API-справочники (загружаются из Intradesk) ─────────────────────────────────
|
||||||
#
|
|
||||||
# Статус эндпоинта:
|
|
||||||
# [OK] — проверено, работает
|
|
||||||
# [?] — нужно проверить, путь предположительный
|
|
||||||
|
|
||||||
SETTINGS_URL = "https://apigw.intradesk.ru/settings/odata"
|
_TASKS_URL = "https://apigw.intradesk.ru/tasklist/odata/v3/tasks"
|
||||||
|
|
||||||
ENTITIES = [
|
# Маппинг поля "type" из dictionaries → ключ entity в БД
|
||||||
{
|
_DICT_TYPE_TO_ENTITY: dict[str, str] = {
|
||||||
"key": "employees",
|
"user": "employees",
|
||||||
"url": f"{SETTINGS_URL}/Employees",
|
"usergroup": "groups",
|
||||||
"params": {"$filter": "isArchived eq false"},
|
"status": "statuses",
|
||||||
"id_field": "id",
|
"priority": "priorities",
|
||||||
"name_field": "name",
|
"tasktype": "tasktypes",
|
||||||
"status": "OK",
|
"tags": "tags",
|
||||||
},
|
"workflow": "workflows",
|
||||||
{
|
"asset": "assets",
|
||||||
"key": "groups",
|
}
|
||||||
"url": f"{SETTINGS_URL}/UserGroups",
|
|
||||||
"params": {"$filter": "isArchived eq false"},
|
|
||||||
"id_field": "id",
|
|
||||||
"name_field": "name",
|
|
||||||
"status": "?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "statuses",
|
|
||||||
"url": f"{SETTINGS_URL}/Statuses",
|
|
||||||
"params": {},
|
|
||||||
"id_field": "id",
|
|
||||||
"name_field": "name",
|
|
||||||
"status": "?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "priorities",
|
|
||||||
"url": f"{SETTINGS_URL}/Priorities",
|
|
||||||
"params": {},
|
|
||||||
"id_field": "id",
|
|
||||||
"name_field": "name",
|
|
||||||
"status": "?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "services",
|
|
||||||
"url": f"{SETTINGS_URL}/ServiceCatalog",
|
|
||||||
"params": {"$filter": "isArchived eq false"},
|
|
||||||
"id_field": "id",
|
|
||||||
"name_field": "name",
|
|
||||||
"status": "?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "tasktypes",
|
|
||||||
"url": f"{SETTINGS_URL}/TaskTypes",
|
|
||||||
"params": {"$filter": "isArchived eq false"},
|
|
||||||
"id_field": "id",
|
|
||||||
"name_field": "name",
|
|
||||||
"status": "?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "tags",
|
|
||||||
"url": f"{SETTINGS_URL}/Tags",
|
|
||||||
"params": {},
|
|
||||||
"id_field": "id",
|
|
||||||
"name_field": "name",
|
|
||||||
"status": "?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "workflows",
|
|
||||||
"url": f"{SETTINGS_URL}/Workflows",
|
|
||||||
"params": {"$filter": "isArchived eq false"},
|
|
||||||
"id_field": "id",
|
|
||||||
"name_field": "name",
|
|
||||||
"status": "?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "sla",
|
|
||||||
"url": f"{SETTINGS_URL}/Sla",
|
|
||||||
"params": {},
|
|
||||||
"id_field": "id",
|
|
||||||
"name_field": "name",
|
|
||||||
"status": "?",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ── вспомогательные ────────────────────────────────────────────────────────────
|
# ── вспомогательные ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -196,12 +130,10 @@ def resolve(entity: str, item_id: int | None) -> str | None:
|
||||||
if item_id is None:
|
if item_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# БД — приоритет
|
|
||||||
name = database.resolve(entity, item_id)
|
name = database.resolve(entity, item_id)
|
||||||
if name:
|
if name:
|
||||||
return name
|
return name
|
||||||
|
|
||||||
# Fallback на статические константы
|
|
||||||
_fallbacks = {
|
_fallbacks = {
|
||||||
"statuses": STATUSES,
|
"statuses": STATUSES,
|
||||||
"priorities": PRIORITIES,
|
"priorities": PRIORITIES,
|
||||||
|
|
@ -222,47 +154,6 @@ def user_type_name(user_type: int) -> str:
|
||||||
|
|
||||||
# ── загрузка из API ────────────────────────────────────────────────────────────
|
# ── загрузка из API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _fetch_all_pages(url: str, params: dict) -> list[dict]:
|
|
||||||
params = {**params, "ApiKey": config.API_KEY}
|
|
||||||
items = []
|
|
||||||
current_url = url
|
|
||||||
|
|
||||||
while current_url:
|
|
||||||
try:
|
|
||||||
resp = requests.get(current_url, params=params, timeout=30)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
log.error(f"Request failed [{current_url}]: {e}")
|
|
||||||
break
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
items.extend(data.get("value", []))
|
|
||||||
current_url = data.get("@odata.nextLink")
|
|
||||||
params = None
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _load_entity(entity: dict) -> int:
|
|
||||||
key = entity["key"]
|
|
||||||
id_field = entity["id_field"]
|
|
||||||
name_field = entity["name_field"]
|
|
||||||
|
|
||||||
items = _fetch_all_pages(entity["url"], entity.get("params", {}))
|
|
||||||
if not items:
|
|
||||||
log.warning(f"[{key}] empty or failed — skipped")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
pairs = [
|
|
||||||
(item[id_field], str(item.get(name_field) or "").strip())
|
|
||||||
for item in items
|
|
||||||
if id_field in item
|
|
||||||
]
|
|
||||||
database.save_ref_data(key, pairs)
|
|
||||||
log.info(f"[{key}] loaded {len(pairs)} records")
|
|
||||||
return len(pairs)
|
|
||||||
|
|
||||||
|
|
||||||
def seed_static():
|
def seed_static():
|
||||||
"""Сидирует в БД известные статические данные (статусы, приоритеты)."""
|
"""Сидирует в БД известные статические данные (статусы, приоритеты)."""
|
||||||
database.save_ref_data("statuses", list(STATUSES.items()))
|
database.save_ref_data("statuses", list(STATUSES.items()))
|
||||||
|
|
@ -271,8 +162,45 @@ def seed_static():
|
||||||
|
|
||||||
|
|
||||||
def load_all():
|
def load_all():
|
||||||
"""Загружает все справочники из API. Неудачные — логирует и пропускает."""
|
"""
|
||||||
log.info("Loading reference data from Intradesk API...")
|
Загружает справочники из поля dictionaries ответа tasklist API.
|
||||||
for entity in ENTITIES:
|
Один запрос заменяет 7+ отдельных запросов к settings/odata.
|
||||||
_load_entity(entity)
|
Обновление накопительное: новые/обновлённые записи добавляются,
|
||||||
|
старые не удаляются (они могут встретиться в старых заявках).
|
||||||
|
"""
|
||||||
|
log.info("Loading reference data from tasklist dictionaries...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
_TASKS_URL,
|
||||||
|
params={"ApiKey": config.API_KEY, "$top": 1},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
log.error(f"Failed to fetch tasklist for dictionaries: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
dictionaries = data.get("dictionaries", [])
|
||||||
|
if not dictionaries:
|
||||||
|
log.warning("dictionaries field is empty or missing in tasklist response")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Группируем по entity
|
||||||
|
buckets: dict[str, list[tuple[int, str]]] = {}
|
||||||
|
for item in dictionaries:
|
||||||
|
dtype = item.get("type", "")
|
||||||
|
entity = _DICT_TYPE_TO_ENTITY.get(dtype)
|
||||||
|
if not entity:
|
||||||
|
continue
|
||||||
|
item_id = item.get("id")
|
||||||
|
name = str(item.get("name") or "").strip()
|
||||||
|
if item_id and name:
|
||||||
|
buckets.setdefault(entity, []).append((item_id, name))
|
||||||
|
|
||||||
|
for entity, pairs in buckets.items():
|
||||||
|
database.save_ref_data(entity, pairs)
|
||||||
|
log.info(f"[{entity}] updated {len(pairs)} records from dictionaries")
|
||||||
|
|
||||||
log.info("Reference data loading complete.")
|
log.info("Reference data loading complete.")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
requests
|
requests==2.32.3
|
||||||
pytelegrambotapi
|
pytelegrambotapi==4.14.1
|
||||||
flask
|
flask==3.1.0
|
||||||
schedule
|
schedule==1.2.2
|
||||||
|
gunicorn==23.0.0
|
||||||
|
|
|
||||||
111
web/server.py
111
web/server.py
|
|
@ -3,7 +3,11 @@ import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
from flask import Flask, Response, jsonify, render_template, request, stream_with_context, abort
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json as _json
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import database
|
import database
|
||||||
|
|
@ -16,6 +20,69 @@ log = logging.getLogger(__name__)
|
||||||
app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"))
|
app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Авторизация ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def check_basic_auth(username, password):
|
||||||
|
"""Проверяет Basic Auth. Логин фиксированный 'admin'."""
|
||||||
|
if not config.DASHBOARD_PASSWORD:
|
||||||
|
return True # Пароль не задан — всё открыто
|
||||||
|
return username == "admin" and password == config.DASHBOARD_PASSWORD
|
||||||
|
|
||||||
|
|
||||||
|
def require_basic_auth(f):
|
||||||
|
"""Декоратор: требует Basic Auth если DASHBOARD_PASSWORD задан."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not config.DASHBOARD_PASSWORD:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
auth = request.authorization
|
||||||
|
if not auth or not check_basic_auth(auth.username, auth.password):
|
||||||
|
return Response(
|
||||||
|
"Требуется авторизация",
|
||||||
|
401,
|
||||||
|
{"WWW-Authenticate": 'Basic realm="IntradBot Dashboard"'},
|
||||||
|
)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def verify_task_token(task_number: str, exp: str, sig: str) -> bool:
|
||||||
|
"""Проверяет HMAC-подпись токена из URL Mini App."""
|
||||||
|
if not config.DASHBOARD_PASSWORD:
|
||||||
|
return True # Защита отключена
|
||||||
|
if not exp or not sig:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
import time as _time
|
||||||
|
if int(exp) < int(_time.time()):
|
||||||
|
log.warning(f"Token expired for task {task_number}")
|
||||||
|
return False
|
||||||
|
msg = f"{task_number}:{exp}".encode()
|
||||||
|
expected = hmac.new(config.MINIAPP_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:16]
|
||||||
|
return hmac.compare_digest(expected, sig)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Token verification error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def require_task_token(f):
|
||||||
|
"""Декоратор: проверяет подписанный токен из URL для API Mini App."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not config.DASHBOARD_PASSWORD:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
# Берём номер задачи из любого параметра запроса
|
||||||
|
task = (request.args.get("number")
|
||||||
|
or request.args.get("task_id")
|
||||||
|
or kwargs.get("file_id", ""))
|
||||||
|
exp = request.args.get("exp", "")
|
||||||
|
sig = request.args.get("sig", "")
|
||||||
|
if not verify_task_token(str(task), exp, sig):
|
||||||
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
# ── Webhook от Intradesk ───────────────────────────────────────────────────────
|
# ── Webhook от Intradesk ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/webhook", methods=["POST"])
|
@app.route("/webhook", methods=["POST"])
|
||||||
|
|
@ -42,11 +109,18 @@ def health():
|
||||||
# ── Дашборд ────────────────────────────────────────────────────────────────────
|
# ── Дашборд ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
|
@require_basic_auth
|
||||||
def dashboard():
|
def dashboard():
|
||||||
return render_template("dashboard.html")
|
return render_template("dashboard.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/miniapp")
|
||||||
|
def miniapp():
|
||||||
|
return render_template("miniapp.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/stream")
|
@app.route("/stream")
|
||||||
|
@require_basic_auth
|
||||||
def stream():
|
def stream():
|
||||||
q = events.subscribe()
|
q = events.subscribe()
|
||||||
|
|
||||||
|
|
@ -71,6 +145,7 @@ def stream():
|
||||||
# ── API заявок ─────────────────────────────────────────────────────────────────
|
# ── API заявок ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/api/tasks/search")
|
@app.route("/api/tasks/search")
|
||||||
|
@require_task_token
|
||||||
def api_tasks_search():
|
def api_tasks_search():
|
||||||
try:
|
try:
|
||||||
number = int(request.args.get("number", 0))
|
number = int(request.args.get("number", 0))
|
||||||
|
|
@ -83,15 +158,17 @@ def api_tasks_search():
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/tasks/active")
|
@app.route("/api/tasks/active")
|
||||||
|
@require_task_token
|
||||||
def api_tasks_active():
|
def api_tasks_active():
|
||||||
filter_expr = request.args.get(
|
filter_expr = request.args.get(
|
||||||
"filter", "(status ne 68045) and (executorgroup eq 243025)"
|
"filter", config.ACTIVE_TASKS_FILTER
|
||||||
)
|
)
|
||||||
tasks = intradesk_api.get_tasks(filter_expr, top=50)
|
tasks = intradesk_api.get_tasks(filter_expr, top=50)
|
||||||
return jsonify(tasks)
|
return jsonify(tasks)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/task/lifetime")
|
@app.route("/api/task/lifetime")
|
||||||
|
@require_task_token
|
||||||
def api_task_lifetime():
|
def api_task_lifetime():
|
||||||
try:
|
try:
|
||||||
task_id = int(request.args.get("task_id", 0))
|
task_id = int(request.args.get("task_id", 0))
|
||||||
|
|
@ -140,14 +217,40 @@ def api_task_execute():
|
||||||
return jsonify({"success": success})
|
return jsonify({"success": success})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Прокси для файлов Intradesk (скрывает API ключ) ───────────────────────────
|
||||||
|
|
||||||
|
@app.route("/api/files/<file_id>")
|
||||||
|
def api_file(file_id):
|
||||||
|
"""Проксирует файл из Intradesk, подставляя API ключ на сервере."""
|
||||||
|
import requests as req
|
||||||
|
thumb = request.args.get("thumb") == "1"
|
||||||
|
url = f"https://apigw.intradesk.ru/files/api/tasks/files/{file_id}"
|
||||||
|
if thumb:
|
||||||
|
url += "/thumbnail"
|
||||||
|
try:
|
||||||
|
r = req.get(url, params={"ApiKey": config.API_KEY}, timeout=30, stream=True)
|
||||||
|
r.raise_for_status()
|
||||||
|
content_type = r.headers.get("Content-Type", "application/octet-stream")
|
||||||
|
return Response(r.content, content_type=content_type)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"File proxy error for {file_id}: {e}")
|
||||||
|
return jsonify({"error": "Файл недоступен"}), 502
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
"""Фабрика приложения — используется Gunicorn и тестами."""
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
def start_server():
|
def start_server():
|
||||||
|
"""Запуск через встроенный Flask — только для локальной разработки."""
|
||||||
ssl_cert = os.path.join("/app/ssl", "cert.pem")
|
ssl_cert = os.path.join("/app/ssl", "cert.pem")
|
||||||
ssl_key = os.path.join("/app/ssl", "key.pem")
|
ssl_key = os.path.join("/app/ssl", "key.pem")
|
||||||
|
|
||||||
if os.path.exists(ssl_cert) and os.path.exists(ssl_key):
|
if os.path.exists(ssl_cert) and os.path.exists(ssl_key):
|
||||||
log.info(f"Web server + dashboard → https://0.0.0.0:{config.PORT} (SSL)")
|
log.info(f"Web server → https://0.0.0.0:{config.PORT} (SSL, dev mode)")
|
||||||
app.run(host="0.0.0.0", port=config.PORT,
|
app.run(host="0.0.0.0", port=config.PORT,
|
||||||
ssl_context=(ssl_cert, ssl_key), use_reloader=False)
|
ssl_context=(ssl_cert, ssl_key), use_reloader=False)
|
||||||
else:
|
else:
|
||||||
log.info(f"Web server + dashboard → http://0.0.0.0:{config.PORT} (no SSL)")
|
log.info(f"Web server → http://0.0.0.0:{config.PORT} (dev mode, no SSL)")
|
||||||
app.run(host="0.0.0.0", port=config.PORT, use_reloader=False)
|
app.run(host="0.0.0.0", port=config.PORT, use_reloader=False)
|
||||||
|
|
|
||||||
1211
web/templates/miniapp.html
Normal file
1211
web/templates/miniapp.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue