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
|
||||
CHANNEL_ID=-1003708972637
|
||||
BASE_TASK_URL=https://cleverparking.intradesk.ru/task/list/
|
||||
WEBHOOK_SECRET=capsspac
|
||||
# ── Обязательные ──────────────────────────────────────────────────────────────
|
||||
TELEGRAM_TOKEN=your_bot_token_here
|
||||
INTRADESK_API_KEY=your_api_key_here
|
||||
CHANNEL_ID=-100123456789
|
||||
|
||||
# ── Необязательные ────────────────────────────────────────────────────────────
|
||||
PORT=8080
|
||||
PUBLIC_URL=https://bot.yourdomain.com
|
||||
BASE_TASK_URL=https://cleverparking.intradesk.ru/task/list/
|
||||
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 . .
|
||||
|
||||
# Gunicorn запускает Flask в продакшене.
|
||||
# intradbot.py сам запускает бота и шедулер в отдельных потоках,
|
||||
# а Gunicorn обслуживает HTTP — один worker достаточно для webhook-нагрузки.
|
||||
CMD ["python", "intradbot.py"]
|
||||
|
|
|
|||
422
bot/telegram.py
422
bot/telegram.py
|
|
@ -1,4 +1,7 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
|
||||
import telebot
|
||||
from telebot import types
|
||||
|
|
@ -8,60 +11,153 @@ import config
|
|||
import database
|
||||
from intradesk import reference
|
||||
from intradesk import api as intradesk_api
|
||||
from intradesk import formatter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
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(
|
||||
types.InlineKeyboardButton("📋 Статус", callback_data=f"st:{task_number}"),
|
||||
types.InlineKeyboardButton("👤 Исполнитель", callback_data=f"ex:{task_number}"),
|
||||
types.InlineKeyboardButton("📄 Предпросмотр", url=sign_task_url(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
|
||||
|
||||
|
||||
# ── Отправка / редактирование поста ───────────────────────────────────────────
|
||||
|
||||
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_number: str,
|
||||
full_text: str,
|
||||
update_summary: str = None,
|
||||
base_text: str,
|
||||
group_id: int = 0,
|
||||
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:
|
||||
bot.edit_message_text(
|
||||
full_text,
|
||||
config.CHANNEL_ID,
|
||||
msg_id,
|
||||
parse_mode="Markdown",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=markup if not closed else None,
|
||||
)
|
||||
except ApiTelegramException as e:
|
||||
if "message is not modified" not in str(e):
|
||||
log.warning(f"edit_message failed for task {task_id}: {e}")
|
||||
|
||||
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}")
|
||||
"""Удаляет старый пост и создаёт новый, накапливая события и сохраняя ответственных."""
|
||||
# Накапливаем события:
|
||||
# - изменения (• ...) дедуплицируем по тексту — одно и то же изменение не дублируем
|
||||
# - комментарии (💬 ...) и описание (📝 ...) всегда добавляем, даже если текст совпадает
|
||||
stored = database.get_stored_events(task_id)
|
||||
stored_set = set(stored)
|
||||
if new_event_lines:
|
||||
added = []
|
||||
for line in new_event_lines:
|
||||
if not line:
|
||||
continue
|
||||
is_change = line.startswith("•")
|
||||
if is_change and line in stored_set:
|
||||
continue # дедупликация только для изменений
|
||||
added.append(line)
|
||||
stored_set.add(line) # обновляем set чтобы не дублировать внутри одного вебхука
|
||||
stored = stored + added
|
||||
else:
|
||||
added = []
|
||||
|
||||
responsible = database.get_responsible(task_id)
|
||||
full_text = _build_full_text(base_text, responsible, stored, new_count=len(added))
|
||||
markup = make_task_buttons(task_number, group_id)
|
||||
|
||||
# Удаляем старый пост
|
||||
old = database.get_message_data(task_id)
|
||||
if old:
|
||||
_delete_message(config.CHANNEL_ID, old["message_id"])
|
||||
|
||||
try:
|
||||
msg = bot.send_message(
|
||||
config.CHANNEL_ID,
|
||||
|
|
@ -70,106 +166,215 @@ def send_or_edit(
|
|||
disable_web_page_preview=True,
|
||||
reply_markup=markup,
|
||||
)
|
||||
database.save_message_id(task_id, msg.message_id, task_number)
|
||||
# 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"send_message failed for task {task_id}: {e}")
|
||||
log.error(f"recreate_post failed for task {task_id}: {e}")
|
||||
|
||||
|
||||
# ── Callbacks: смена статуса ──────────────────────────────────────────────────
|
||||
|
||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("st:"))
|
||||
def cb_show_statuses(call):
|
||||
"""Показывает список статусов для выбора."""
|
||||
task_number = call.data[3:]
|
||||
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"))
|
||||
|
||||
task_number, group_id = _parse2(call.data[3:])
|
||||
chat_id, msg_id = _current_msg(task_number, call)
|
||||
try:
|
||||
bot.send_message(
|
||||
call.message.chat.id,
|
||||
f"Заявка *\\#{task_number}* — выберите новый статус:",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=markup,
|
||||
reply_to_message_id=call.message.message_id,
|
||||
bot.edit_message_reply_markup(
|
||||
chat_id, msg_id,
|
||||
reply_markup=_make_status_keyboard(task_number, group_id),
|
||||
)
|
||||
bot.answer_callback_query(call.id)
|
||||
except Exception as e:
|
||||
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:"))
|
||||
def cb_set_status(call):
|
||||
"""Применяет выбранный статус через API."""
|
||||
_, task_number, status_id = call.data.split(":")
|
||||
status_name = reference.STATUSES.get(int(status_id), f"#{status_id}")
|
||||
# ss:{task_number}:{group_id}:{status_id}
|
||||
parts = call.data.split(":")
|
||||
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:
|
||||
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:
|
||||
bot.answer_callback_query(call.id, "❌ Ошибка при изменении статуса", show_alert=True)
|
||||
bot.answer_callback_query(call.id, "❌ Ошибка изменения статуса", show_alert=True)
|
||||
|
||||
|
||||
# ── Callbacks: назначение исполнителя ─────────────────────────────────────────
|
||||
|
||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("ex:"))
|
||||
def cb_show_executors(call):
|
||||
"""Показывает список сотрудников для назначения исполнителем."""
|
||||
task_number = call.data[3:]
|
||||
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"))
|
||||
|
||||
task_number, group_id = _parse2(call.data[3:])
|
||||
chat_id, msg_id = _current_msg(task_number, call)
|
||||
try:
|
||||
bot.send_message(
|
||||
call.message.chat.id,
|
||||
f"Заявка *\\#{task_number}* — выберите исполнителя:",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=markup,
|
||||
reply_to_message_id=call.message.message_id,
|
||||
bot.edit_message_reply_markup(
|
||||
chat_id, msg_id,
|
||||
reply_markup=_make_executor_keyboard(task_number, group_id),
|
||||
)
|
||||
bot.answer_callback_query(call.id)
|
||||
except Exception as e:
|
||||
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:"))
|
||||
def cb_set_executor(call):
|
||||
"""Назначает выбранного сотрудника исполнителем через API."""
|
||||
_, task_number, emp_id = call.data.split(":")
|
||||
emp_name = database.resolve("employees", int(emp_id)) or f"#{emp_id}"
|
||||
# se:{task_number}:{group_id}:{emp_id}
|
||||
parts = call.data.split(":")
|
||||
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:
|
||||
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:
|
||||
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")
|
||||
def cb_delete(call):
|
||||
success = intradesk_api.assign_executor(int(task_number), group_id=group_id)
|
||||
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)
|
||||
_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):
|
||||
|
|
@ -179,8 +384,37 @@ def _delete_message(chat_id, message_id):
|
|||
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():
|
||||
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 sys
|
||||
|
||||
TOKEN = os.environ.get("TELEGRAM_TOKEN", "")
|
||||
API_KEY = os.environ.get("INTRADESK_API_KEY", "")
|
||||
BASE_TASK_URL = os.environ.get("BASE_TASK_URL", "https://cleverparking.intradesk.ru/task/list/")
|
||||
CHANNEL_ID = int(os.environ.get("CHANNEL_ID", "0"))
|
||||
DB_FILE = os.environ.get("DB_FILE", "/app/data/intradbot.db")
|
||||
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
|
||||
PORT = int(os.environ.get("PORT", "8080"))
|
||||
|
||||
def _require(name: str) -> str:
|
||||
"""Читает переменную окружения. Если не задана — завершает процесс с понятной ошибкой."""
|
||||
value = os.environ.get(name, "").strip()
|
||||
if not value:
|
||||
print(f"[FATAL] Обязательная переменная окружения не задана: {name}", file=sys.stderr)
|
||||
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.
184
database.py
184
database.py
|
|
@ -1,10 +1,25 @@
|
|||
"""
|
||||
Слой доступа к данным (SQLite).
|
||||
|
||||
Использует thread-local соединения — каждый поток получает своё соединение
|
||||
и переиспользует его, вместо создания нового на каждый запрос.
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
import config
|
||||
|
||||
_local = threading.local()
|
||||
|
||||
def _connect():
|
||||
conn = sqlite3.connect(config.DB_FILE)
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""Возвращает соединение для текущего потока (создаёт при первом вызове)."""
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -14,10 +29,21 @@ def init_db():
|
|||
CREATE TABLE IF NOT EXISTS messages (
|
||||
task_id INTEGER PRIMARY KEY,
|
||||
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("""
|
||||
CREATE TABLE IF NOT EXISTS ref_data (
|
||||
entity TEXT NOT NULL,
|
||||
|
|
@ -26,24 +52,97 @@ def init_db():
|
|||
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()
|
||||
|
||||
|
||||
# ── messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_message_id(task_id: int):
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
row = _connect().execute(
|
||||
"SELECT message_id FROM messages WHERE task_id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return row["message_id"] if row else None
|
||||
|
||||
|
||||
def save_message_id(task_id: int, message_id: int, task_number: str = None):
|
||||
with _connect() as conn:
|
||||
def get_message_data(task_id: int) -> dict | None:
|
||||
row = _connect().execute(
|
||||
"SELECT message_id, task_number, group_id, base_text FROM messages WHERE task_id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
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) VALUES (?, ?, ?)",
|
||||
(task_id, message_id, task_number),
|
||||
"""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()
|
||||
|
||||
|
|
@ -51,22 +150,18 @@ def save_message_id(task_id: int, message_id: int, task_number: str = None):
|
|||
# ── ref_data ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def save_ref_data(entity: str, items: list[tuple[int, str]]):
|
||||
"""Перезаписывает весь справочник entity списком пар (id, name)."""
|
||||
with _connect() as conn:
|
||||
conn.execute("DELETE FROM ref_data WHERE entity = ?", (entity,))
|
||||
conn = _connect()
|
||||
conn.executemany(
|
||||
"INSERT INTO ref_data (entity, id, name) VALUES (?, ?, ?)",
|
||||
"INSERT OR REPLACE INTO ref_data (entity, id, name) VALUES (?, ?, ?)",
|
||||
[(entity, item_id, name) for item_id, name in items],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def resolve(entity: str, item_id: int | None) -> str | None:
|
||||
"""Возвращает name по entity + id. None если не найдено."""
|
||||
if item_id is None:
|
||||
return None
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
row = _connect().execute(
|
||||
"SELECT name FROM ref_data WHERE entity = ? AND id = ?",
|
||||
(entity, item_id),
|
||||
).fetchone()
|
||||
|
|
@ -74,11 +169,60 @@ def resolve(entity: str, item_id: int | None) -> str | None:
|
|||
|
||||
|
||||
def get_all_ref(entity: str) -> list[dict]:
|
||||
"""Возвращает все записи справочника как список {id, name}."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
rows = _connect().execute(
|
||||
"SELECT id, name FROM ref_data WHERE entity = ? ORDER BY name",
|
||||
(entity,),
|
||||
).fetchall()
|
||||
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:
|
||||
- "8080:8080"
|
||||
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 os
|
||||
import threading
|
||||
|
|
@ -31,9 +42,10 @@ def main():
|
|||
reference.seed_static()
|
||||
reference.load_all()
|
||||
|
||||
threading.Thread(target=start_bot, daemon=True).start()
|
||||
threading.Thread(target=start_server, daemon=True).start()
|
||||
threading.Thread(target=start_bot, daemon=True, name="telegram-bot").start()
|
||||
threading.Thread(target=start_server, daemon=True, name="web-server").start()
|
||||
|
||||
# Обновляем справочники каждый понедельник (новые сотрудники, группы и т.д.)
|
||||
schedule.every().monday.at("10:00").do(reference.load_all)
|
||||
|
||||
log.info("IntradBot is running. Waiting for webhooks...")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
"""
|
||||
Парсинг и форматирование данных из вебхуков и API Intradesk.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
import config
|
||||
import database
|
||||
from intradesk import reference
|
||||
|
||||
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] = {
|
||||
"status": "statuses",
|
||||
"priority": "priorities",
|
||||
|
|
@ -20,8 +32,7 @@ BLOCK_TO_ENTITY: dict[str, str] = {
|
|||
"sla": "sla",
|
||||
}
|
||||
|
||||
# Человекочитаемые названия полей
|
||||
FIELD_LABELS = {
|
||||
FIELD_LABELS: dict[str, str] = {
|
||||
"status": "Статус",
|
||||
"priority": "Приоритет",
|
||||
"executor": "Исполнитель",
|
||||
|
|
@ -38,16 +49,51 @@ FIELD_LABELS = {
|
|||
"tags": "Теги",
|
||||
}
|
||||
|
||||
# Поля, изменение которых не выводится в уведомлении
|
||||
IGNORED_FIELDS = {
|
||||
IGNORED_FIELDS: set[str] = {
|
||||
"lifetime", "updatedat", "updatedby", "updatedchannel",
|
||||
"reactiondatefact", "resolutiondatefact", "resolutionleftminutes",
|
||||
"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):
|
||||
"""Парсит JSON-строку или возвращает как есть."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (dict, list)):
|
||||
|
|
@ -61,7 +107,6 @@ def _parse_json_field(raw):
|
|||
|
||||
|
||||
def _scalar(raw):
|
||||
"""Возвращает скалярное значение поля Fields (Value или Id)."""
|
||||
parsed = _parse_json_field(raw)
|
||||
if isinstance(parsed, dict):
|
||||
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:
|
||||
"""
|
||||
Достаёт человекочитаемое имя из объекта 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:
|
||||
return None
|
||||
|
||||
raw_name = v.get("currentname") or v.get("name")
|
||||
|
||||
# name — объект {"ru": ..., "en": ...}
|
||||
raw_name = v.get("currentname") or v.get("name") or v.get("Name")
|
||||
if isinstance(raw_name, dict):
|
||||
raw_name = raw_name.get("ru") or raw_name.get("en") or None
|
||||
|
||||
if raw_name:
|
||||
return str(raw_name).strip() or None
|
||||
|
||||
# name отсутствует или null — резольвинг через БД/статик
|
||||
item_id = v.get("id")
|
||||
name_str = str(raw_name).strip() or None
|
||||
if name_str and blockname in ("executor", "initiator"):
|
||||
return _shorten_name(name_str)
|
||||
return name_str
|
||||
item_id = v.get("id") or v.get("Id")
|
||||
entity = BLOCK_TO_ENTITY.get(blockname)
|
||||
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
|
||||
|
||||
|
||||
def get_executor_group(fields: dict) -> str | None:
|
||||
"""Группа исполнителей (Type=10 в taskusergroups)."""
|
||||
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_initiator(fields: dict) -> str | None:
|
||||
"""Инициатор заявки (Type=10 в taskusers)."""
|
||||
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 user.get("Name", "").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def is_final(fields: dict) -> bool:
|
||||
"""Возвращает 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]]:
|
||||
def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||
"""
|
||||
Разбирает lifetime-поле вебхука.
|
||||
Разбирает одно сырое событие из lifetime в нормализованный ParsedEvent.
|
||||
|
||||
Возвращает:
|
||||
changes — строки вида «• Поле: было → стало»
|
||||
comments — строки вида «*Автор*: текст»
|
||||
Используется как в parse_lifetime() (вебхук), так и в format_lifetime_api_events()
|
||||
(прямой API). Единственный источник правды для логики разбора событий.
|
||||
"""
|
||||
changes: list[str] = []
|
||||
comments: list[str] = []
|
||||
|
||||
lifetime = _parse_json_field(fields.get("lifetime", "{}"))
|
||||
if not isinstance(lifetime, dict):
|
||||
return changes, comments
|
||||
|
||||
for entry in lifetime.get("Data", []):
|
||||
username = entry.get("username", "Неизвестный")
|
||||
|
||||
for event in entry.get("events", {}).get("Data", []):
|
||||
etype = event.get("type")
|
||||
blockname = event.get("blockname", "")
|
||||
short_user = _shorten_name(username) or "Неизвестный"
|
||||
|
||||
# ── Комментарии ────────────────────────────────────────────────
|
||||
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 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:
|
||||
continue
|
||||
return None
|
||||
|
||||
# ── Доп. поля Boolean (ТО, Созвон и т.д.) ────────────────────────────────
|
||||
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 ──────────────
|
||||
# ── Изменения с 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} → *снят*")
|
||||
|
||||
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 ''}*")
|
||||
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
|
||||
|
||||
|
||||
# ── Публичные функции ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_description_event(fields: dict) -> str | None:
|
||||
description = _scalar(fields.get("description"))
|
||||
if not description:
|
||||
return None
|
||||
description = re.sub(r'<[^>]+>', ' ', description).strip()
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
if not description:
|
||||
return None
|
||||
truncated = description[:100] + ('…' if len(description) > 100 else '')
|
||||
return f"📝 {truncated}"
|
||||
|
||||
|
||||
def parse_lifetime(fields: dict, max_events: int = 5) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Разбирает lifetime-поле вебхука (вложенная структура).
|
||||
Возвращает (changes, comments) — последние max_events событий.
|
||||
"""
|
||||
all_events: list[ParsedEvent] = []
|
||||
|
||||
lifetime = _parse_json_field(fields.get("lifetime", "{}"))
|
||||
if not isinstance(lifetime, dict):
|
||||
return [], []
|
||||
|
||||
for entry in lifetime.get("Data", []):
|
||||
if entry.get("lifetimetype") == 10:
|
||||
continue
|
||||
username = entry.get("username", "Неизвестный")
|
||||
for event in entry.get("events", {}).get("Data", []):
|
||||
parsed = _parse_single_event(event, username)
|
||||
if parsed:
|
||||
all_events.append(parsed)
|
||||
|
||||
# ── Событие без значения (теги, файлы, эскалация) ─────────────
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Резолвит ID поля верхнего уровня через справочники."""
|
||||
raw = _parse_json_field(fields.get(field, {}))
|
||||
if not isinstance(raw, dict):
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""Полная карточка задачи для основного поста в канале."""
|
||||
op_type = payload.get("Event", {}).get("OperationType", "")
|
||||
fields = payload.get("Fields", {})
|
||||
|
||||
number = fields.get("Number", "?")
|
||||
name = _scalar(fields.get("name")) or "—"
|
||||
executor_grp = get_executor_group(fields)
|
||||
initiator = get_initiator(fields)
|
||||
|
||||
applicant = get_initiator(fields)
|
||||
executors = get_all_executors(fields)
|
||||
obj_name = get_object_name(fields)
|
||||
to_flag = is_to(fields)
|
||||
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, ""]
|
||||
|
||||
if status:
|
||||
lines.append(f"📌 Статус: {status}")
|
||||
if priority:
|
||||
lines.append(f"🔺 Приоритет: {priority}")
|
||||
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})")
|
||||
|
||||
if to_flag: lines.append("🔧 *ТО*")
|
||||
if status: lines.append(f"📌 Статус: {status}")
|
||||
if obj_name: lines.append(f"🏢 Объект: {obj_name}")
|
||||
if applicant: lines.append(f"👤 Автор: {applicant}")
|
||||
if executors: lines.append(f"👔 Исполнитель: {', '.join(executors)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_update_summary(changes: list[str], comments: list[str]) -> str | None:
|
||||
"""Короткий reply-пост при обновлении (триггерит пуш подписчикам канала)."""
|
||||
if not changes and not comments:
|
||||
return None
|
||||
lines = ["🔄 *Изменено:*"]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import database
|
||||
import events
|
||||
from bot import telegram as bot
|
||||
from bot import telegram as tg
|
||||
from intradesk import formatter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -24,7 +25,20 @@ def handle_webhook(payload: dict):
|
|||
return
|
||||
|
||||
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({
|
||||
"type": "webhook",
|
||||
|
|
@ -37,16 +51,18 @@ def handle_webhook(payload: dict):
|
|||
"changes": changes,
|
||||
"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:
|
||||
summary = formatter.format_update_summary(changes, comments)
|
||||
bot.send_or_edit(task_id, number, full_text, summary, closed=closed)
|
||||
# ── Гифка для ТО ──────────────────────────────────────────────────────
|
||||
# Отправляем (и удаляем предыдущую) при любом вебхуке если ТО = да
|
||||
if is_to_now:
|
||||
tg.send_gif_for_to(task_id)
|
||||
|
||||
except Exception:
|
||||
log.exception("Error while handling webhook")
|
||||
|
|
|
|||
|
|
@ -101,88 +101,22 @@ EVENT_TYPES: dict[int, str] = {
|
|||
400: "Эскалация заявки",
|
||||
}
|
||||
|
||||
|
||||
# ── API-справочники (загружаются из Intradesk) ─────────────────────────────────
|
||||
#
|
||||
# Статус эндпоинта:
|
||||
# [OK] — проверено, работает
|
||||
# [?] — нужно проверить, путь предположительный
|
||||
|
||||
SETTINGS_URL = "https://apigw.intradesk.ru/settings/odata"
|
||||
_TASKS_URL = "https://apigw.intradesk.ru/tasklist/odata/v3/tasks"
|
||||
|
||||
ENTITIES = [
|
||||
{
|
||||
"key": "employees",
|
||||
"url": f"{SETTINGS_URL}/Employees",
|
||||
"params": {"$filter": "isArchived eq false"},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "OK",
|
||||
},
|
||||
{
|
||||
"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": "?",
|
||||
},
|
||||
]
|
||||
# Маппинг поля "type" из dictionaries → ключ entity в БД
|
||||
_DICT_TYPE_TO_ENTITY: dict[str, str] = {
|
||||
"user": "employees",
|
||||
"usergroup": "groups",
|
||||
"status": "statuses",
|
||||
"priority": "priorities",
|
||||
"tasktype": "tasktypes",
|
||||
"tags": "tags",
|
||||
"workflow": "workflows",
|
||||
"asset": "assets",
|
||||
}
|
||||
|
||||
|
||||
# ── вспомогательные ────────────────────────────────────────────────────────────
|
||||
|
|
@ -196,12 +130,10 @@ def resolve(entity: str, item_id: int | None) -> str | None:
|
|||
if item_id is None:
|
||||
return None
|
||||
|
||||
# БД — приоритет
|
||||
name = database.resolve(entity, item_id)
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Fallback на статические константы
|
||||
_fallbacks = {
|
||||
"statuses": STATUSES,
|
||||
"priorities": PRIORITIES,
|
||||
|
|
@ -222,47 +154,6 @@ def user_type_name(user_type: int) -> str:
|
|||
|
||||
# ── загрузка из 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():
|
||||
"""Сидирует в БД известные статические данные (статусы, приоритеты)."""
|
||||
database.save_ref_data("statuses", list(STATUSES.items()))
|
||||
|
|
@ -271,8 +162,45 @@ def seed_static():
|
|||
|
||||
|
||||
def load_all():
|
||||
"""Загружает все справочники из API. Неудачные — логирует и пропускает."""
|
||||
log.info("Loading reference data from Intradesk API...")
|
||||
for entity in ENTITIES:
|
||||
_load_entity(entity)
|
||||
"""
|
||||
Загружает справочники из поля dictionaries ответа tasklist API.
|
||||
Один запрос заменяет 7+ отдельных запросов к settings/odata.
|
||||
Обновление накопительное: новые/обновлённые записи добавляются,
|
||||
старые не удаляются (они могут встретиться в старых заявках).
|
||||
"""
|
||||
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.")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
requests
|
||||
pytelegrambotapi
|
||||
flask
|
||||
schedule
|
||||
requests==2.32.3
|
||||
pytelegrambotapi==4.14.1
|
||||
flask==3.1.0
|
||||
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 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 database
|
||||
|
|
@ -16,6 +20,69 @@ log = logging.getLogger(__name__)
|
|||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/webhook", methods=["POST"])
|
||||
|
|
@ -42,11 +109,18 @@ def health():
|
|||
# ── Дашборд ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/")
|
||||
@require_basic_auth
|
||||
def dashboard():
|
||||
return render_template("dashboard.html")
|
||||
|
||||
|
||||
@app.route("/miniapp")
|
||||
def miniapp():
|
||||
return render_template("miniapp.html")
|
||||
|
||||
|
||||
@app.route("/stream")
|
||||
@require_basic_auth
|
||||
def stream():
|
||||
q = events.subscribe()
|
||||
|
||||
|
|
@ -71,6 +145,7 @@ def stream():
|
|||
# ── API заявок ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/tasks/search")
|
||||
@require_task_token
|
||||
def api_tasks_search():
|
||||
try:
|
||||
number = int(request.args.get("number", 0))
|
||||
|
|
@ -83,15 +158,17 @@ def api_tasks_search():
|
|||
|
||||
|
||||
@app.route("/api/tasks/active")
|
||||
@require_task_token
|
||||
def api_tasks_active():
|
||||
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)
|
||||
return jsonify(tasks)
|
||||
|
||||
|
||||
@app.route("/api/task/lifetime")
|
||||
@require_task_token
|
||||
def api_task_lifetime():
|
||||
try:
|
||||
task_id = int(request.args.get("task_id", 0))
|
||||
|
|
@ -140,14 +217,40 @@ def api_task_execute():
|
|||
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():
|
||||
"""Запуск через встроенный Flask — только для локальной разработки."""
|
||||
ssl_cert = os.path.join("/app/ssl", "cert.pem")
|
||||
ssl_key = os.path.join("/app/ssl", "key.pem")
|
||||
|
||||
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,
|
||||
ssl_context=(ssl_cert, ssl_key), use_reloader=False)
|
||||
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)
|
||||
|
|
|
|||
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