This commit is contained in:
dv 2026-03-15 20:34:12 +03:00
parent fe02b5139a
commit e94e1a3faa
18 changed files with 24454 additions and 455 deletions

30
.dockerignore Normal file
View 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

View file

@ -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
View 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
View 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()` | 🟢 Хорошая практика |

View file

@ -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"]

View file

@ -1,4 +1,7 @@
import hashlib
import hmac
import logging
import time
import telebot
from telebot import types
@ -8,168 +11,370 @@ 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:
try:
msg = bot.send_message(
config.CHANNEL_ID,
full_text,
parse_mode="Markdown",
disable_web_page_preview=True,
reply_markup=markup,
)
database.save_message_id(task_id, msg.message_id, task_number)
except ApiTelegramException as e:
log.error(f"send_message failed for task {task_id}: {e}")
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,
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: смена статуса ──────────────────────────────────────────────────
@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:
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:"))
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:
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:"))
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)

View file

@ -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

File diff suppressed because it is too large Load diff

BIN
data/intradbot.db Normal file

Binary file not shown.

View file

@ -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)
conn.row_factory = sqlite3.Row
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,59 +52,177 @@ 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(
"SELECT message_id FROM messages WHERE task_id = ?", (task_id,)
).fetchone()
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:
conn.execute(
"INSERT OR REPLACE INTO messages (task_id, message_id, task_number) VALUES (?, ?, ?)",
(task_id, message_id, task_number),
)
conn.commit()
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, 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 ──────────────────────────────────────────────────────────────────
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.executemany(
"INSERT INTO ref_data (entity, id, name) VALUES (?, ?, ?)",
[(entity, item_id, name) for item_id, name in items],
)
conn.commit()
conn = _connect()
conn.executemany(
"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(
"SELECT name FROM ref_data WHERE entity = ? AND id = ?",
(entity, item_id),
).fetchone()
row = _connect().execute(
"SELECT name FROM ref_data WHERE entity = ? AND id = ?",
(entity, item_id),
).fetchone()
return row["name"] if row else None
def get_all_ref(entity: str) -> list[dict]:
"""Возвращает все записи справочника как список {id, name}."""
with _connect() as conn:
rows = conn.execute(
"SELECT id, name FROM ref_data WHERE entity = ? ORDER BY name",
(entity,),
).fetchall()
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()

View file

@ -6,4 +6,5 @@ services:
ports:
- "8080:8080"
volumes:
- ./data:/app/data # БД и логи хранятся вне контейнера
- ./data:/app/data
- ./ssl:/app/ssl:ro # ← реальные файлы без симлинков

View file

@ -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...")

View file

@ -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):
# ── Ядро: разбор одного события ───────────────────────────────────────────────
def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
"""
Разбирает одно сырое событие из 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
for group in raw.get("TaskUserGroups", []):
if group.get("Type") == 10:
return group.get("Name", "").strip() or 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 ─────────────────────────
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
def get_initiator(fields: dict) -> str | None:
"""Инициатор заявки (Type=10 в taskusers)."""
raw = _parse_json_field(fields.get("taskusers", {}))
if not isinstance(raw, dict):
# ── Публичные функции ─────────────────────────────────────────────────────────
def get_description_event(fields: dict) -> str | None:
description = _scalar(fields.get("description"))
if not description:
return None
for user in raw.get("TaskUsers", []):
if user.get("Type") == 10:
return user.get("Name", "").strip() or None
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 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_lifetime(fields: dict, max_events: int = 5) -> tuple[list[str], list[str]]:
"""
Разбирает lifetime-поле вебхука.
Возвращает:
changes строки вида « Поле: было стало»
comments строки вида «*Автор*: текст»
Разбирает lifetime-поле вебхука (вложенная структура).
Возвращает (changes, comments) последние max_events событий.
"""
changes: list[str] = []
comments: list[str] = []
all_events: list[ParsedEvent] = []
lifetime = _parse_json_field(fields.get("lifetime", "{}"))
if not isinstance(lifetime, dict):
return changes, comments
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", []):
etype = event.get("type")
blockname = event.get("blockname", "")
# ── Комментарии ────────────────────────────────────────────────
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}")
parsed = _parse_single_event(event, username)
if parsed:
all_events.append(parsed)
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)
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} обновлена*"
op_type = payload.get("Event", {}).get("OperationType", "")
fields = payload.get("Fields", {})
number = fields.get("Number", "?")
name = _scalar(fields.get("name")) or ""
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")
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 = ["🔄 *Изменено:*"]

View file

@ -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")

View file

@ -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.")

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff