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 TELEGRAM_TOKEN=your_bot_token_here
CHANNEL_ID=-1003708972637 INTRADESK_API_KEY=your_api_key_here
BASE_TASK_URL=https://cleverparking.intradesk.ru/task/list/ CHANNEL_ID=-100123456789
WEBHOOK_SECRET=capsspac
# ── Необязательные ────────────────────────────────────────────────────────────
PORT=8080 PORT=8080
PUBLIC_URL=https://bot.yourdomain.com
BASE_TASK_URL=https://cleverparking.intradesk.ru/task/list/
DB_FILE=/app/data/intradbot.db DB_FILE=/app/data/intradbot.db
WEBHOOK_SECRET=your_webhook_secret
GIF_URL=https://media.tenor.com/OzN-0kxqXAgAAAAi/cat.gif
# Пароль для дашборда (оставьте пустым — дашборд открыт)
DASHBOARD_PASSWORD=
# Секрет для подписи ссылок Mini App (по умолчанию берётся из WEBHOOK_SECRET)
MINIAPP_SECRET=
# ── Бизнес-параметры ──────────────────────────────────────────────────────────
# ID сотрудников для кнопки "Назначить исполнителя" (через запятую)
EXECUTOR_USER_IDS=2227907,631481,525111
# Фильтр активных заявок на дашборде
ACTIVE_TASKS_FILTER=(status ne 68045) and (executorgroup eq 243025)

20
.gitignore vendored Normal file
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 . . COPY . .
# Gunicorn запускает Flask в продакшене.
# intradbot.py сам запускает бота и шедулер в отдельных потоках,
# а Gunicorn обслуживает HTTP — один worker достаточно для webhook-нагрузки.
CMD ["python", "intradbot.py"] CMD ["python", "intradbot.py"]

View file

@ -1,4 +1,7 @@
import hashlib
import hmac
import logging import logging
import time
import telebot import telebot
from telebot import types from telebot import types
@ -8,168 +11,370 @@ import config
import database import database
from intradesk import reference from intradesk import reference
from intradesk import api as intradesk_api from intradesk import api as intradesk_api
from intradesk import formatter
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
bot = telebot.TeleBot(config.TOKEN, parse_mode="Markdown") bot = telebot.TeleBot(config.TOKEN, parse_mode="Markdown")
# Сотрудники, доступные для назначения исполнителем (ID из Intradesk)
EXECUTOR_USER_IDS = config.EXECUTOR_USER_IDS
# ── Кнопки под постом ───────────────────────────────────────────────────────── # Разделитель перед секцией "В работе" в тексте поста
_RESP_SEP = "\n\n─────────────────────"
def make_task_buttons(task_number: str, closed: bool = False) -> types.InlineKeyboardMarkup:
markup = types.InlineKeyboardMarkup(row_width=2) # ── Текст с футером "В работе" ────────────────────────────────────────────────
if not closed:
def _build_full_text(
base_text: str,
responsible: list[dict],
events_lines: list[str] | None = None,
new_count: int = 0,
) -> str:
"""
Собирает итоговый текст поста:
1. Основной текст (base_text)
2. «📋 Последние события» все события кроме последних new_count
3. «🆕 Новое» последние new_count событий (все новые из текущего вебхука)
4. Разделитель + «В работе» всегда самый последний блок
"""
clean = base_text.split(_RESP_SEP)[0]
result = clean
if events_lines:
if new_count > 0:
history = events_lines[:-new_count]
newest = events_lines[-new_count:]
else:
history = events_lines
newest = []
# Показываем последние 6 из истории
history = history[-6:]
if history:
result += "\n\n📋 *Последние события:*\n" + "\n".join(history)
if newest:
result += "\n\n🆕 *Новое:*\n" + "\n".join(newest)
if responsible:
names = ", ".join(r["user_name"] for r in responsible)
result += _RESP_SEP + f"\n🙋 В работе: {names}"
return result
# ── Клавиатуры ────────────────────────────────────────────────────────────────
def sign_task_url(task_number: str) -> str:
"""Генерирует подписанную ссылку на Mini App с токеном (TTL 30 дней)."""
expires = int(time.time()) + 30 * 24 * 3600
msg = f"{task_number}:{expires}".encode()
sig = hmac.new(config.MINIAPP_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:16]
return f"{config.PUBLIC_URL}/miniapp?task={task_number}&exp={expires}&sig={sig}"
def make_task_buttons(task_number: str, group_id: int) -> types.InlineKeyboardMarkup:
gid = group_id or 0
markup = types.InlineKeyboardMarkup(row_width=1)
#markup.add(
# types.InlineKeyboardButton("📋 Статус", callback_data=f"st:{task_number}:{gid}"),
# types.InlineKeyboardButton("👤 Исполнитель", callback_data=f"ex:{task_number}:{gid}"),
# types.InlineKeyboardButton("🙋 Взять", callback_data=f"take:{task_number}"),
# types.InlineKeyboardButton("❌ Снять", callback_data=f"drop:{task_number}"),
#)
# Кнопка Mini App — полная карточка заявки (только если PUBLIC_URL задан)
if config.PUBLIC_URL:
markup.add( markup.add(
types.InlineKeyboardButton("📋 Статус", callback_data=f"st:{task_number}"), types.InlineKeyboardButton("📄 Предпросмотр", url=sign_task_url(task_number)),
types.InlineKeyboardButton("👤 Исполнитель", callback_data=f"ex:{task_number}"), types.InlineKeyboardButton("🔗 Открыть в IntraDesk", url=config.BASE_TASK_URL + task_number),
)
else:
markup.add(
types.InlineKeyboardButton("🔗 Открыть заявку", url=config.BASE_TASK_URL + task_number),
) )
return markup return markup
# ── Отправка / редактирование поста ───────────────────────────────────────────
def send_or_edit( def _make_status_keyboard(task_number: str, group_id: int) -> types.InlineKeyboardMarkup:
markup = types.InlineKeyboardMarkup(row_width=1)
for sid, name in reference.STATUSES.items():
markup.add(types.InlineKeyboardButton(
name, callback_data=f"ss:{task_number}:{group_id}:{sid}"
))
markup.add(types.InlineKeyboardButton("↩ Назад", callback_data=f"back:{task_number}:{group_id}"))
return markup
def _make_executor_keyboard(task_number: str, group_id: int) -> types.InlineKeyboardMarkup:
markup = types.InlineKeyboardMarkup(row_width=1)
for uid in EXECUTOR_USER_IDS:
name = database.resolve("employees", uid) or f"ID {uid}"
markup.add(types.InlineKeyboardButton(
name, callback_data=f"se:{task_number}:{group_id}:{uid}"
))
markup.add(types.InlineKeyboardButton(
" Только группа", callback_data=f"su:{task_number}:{group_id}"
))
markup.add(types.InlineKeyboardButton("↩ Назад", callback_data=f"back:{task_number}:{group_id}"))
return markup
# ── Публикация / пересоздание поста ──────────────────────────────────────────
def recreate_post(
task_id: int, task_id: int,
task_number: str, task_number: str,
full_text: str, base_text: str,
update_summary: str = None, group_id: int = 0,
closed: bool = False, closed: bool = False,
new_event_lines: list[str] | None = None,
): ):
markup = make_task_buttons(task_number, closed=closed) """Удаляет старый пост и создаёт новый, накапливая события и сохраняя ответственных."""
msg_id = database.get_message_id(task_id) # Накапливаем события:
# - изменения (• ...) дедуплицируем по тексту — одно и то же изменение не дублируем
if msg_id: # - комментарии (💬 ...) и описание (📝 ...) всегда добавляем, даже если текст совпадает
try: stored = database.get_stored_events(task_id)
bot.edit_message_text( stored_set = set(stored)
full_text, if new_event_lines:
config.CHANNEL_ID, added = []
msg_id, for line in new_event_lines:
parse_mode="Markdown", if not line:
disable_web_page_preview=True, continue
reply_markup=markup if not closed else None, is_change = line.startswith("")
) if is_change and line in stored_set:
except ApiTelegramException as e: continue # дедупликация только для изменений
if "message is not modified" not in str(e): added.append(line)
log.warning(f"edit_message failed for task {task_id}: {e}") stored_set.add(line) # обновляем set чтобы не дублировать внутри одного вебхука
stored = stored + added
if update_summary:
try:
bot.send_message(
config.CHANNEL_ID,
update_summary,
reply_to_message_id=msg_id,
parse_mode="Markdown",
)
except ApiTelegramException as e:
log.warning(f"send reply failed for task {task_id}: {e}")
else: else:
try: added = []
msg = bot.send_message(
config.CHANNEL_ID, responsible = database.get_responsible(task_id)
full_text, full_text = _build_full_text(base_text, responsible, stored, new_count=len(added))
parse_mode="Markdown", markup = make_task_buttons(task_number, group_id)
disable_web_page_preview=True,
reply_markup=markup, # Удаляем старый пост
) old = database.get_message_data(task_id)
database.save_message_id(task_id, msg.message_id, task_number) if old:
except ApiTelegramException as e: _delete_message(config.CHANNEL_ID, old["message_id"])
log.error(f"send_message failed for task {task_id}: {e}")
try:
msg = bot.send_message(
config.CHANNEL_ID,
full_text,
parse_mode="Markdown",
disable_web_page_preview=True,
reply_markup=markup,
)
# save_message_id сначала создаёт/обновляет строку, потом сохраняем события
database.save_message_id(task_id, msg.message_id, task_number, group_id, base_text)
if added:
database.save_stored_events(task_id, stored)
except ApiTelegramException as e:
log.error(f"recreate_post failed for task {task_id}: {e}")
# ── Callbacks: смена статуса ────────────────────────────────────────────────── # ── Callbacks: смена статуса ──────────────────────────────────────────────────
@bot.callback_query_handler(func=lambda c: c.data.startswith("st:")) @bot.callback_query_handler(func=lambda c: c.data.startswith("st:"))
def cb_show_statuses(call): def cb_show_statuses(call):
"""Показывает список статусов для выбора.""" task_number, group_id = _parse2(call.data[3:])
task_number = call.data[3:] chat_id, msg_id = _current_msg(task_number, call)
markup = types.InlineKeyboardMarkup(row_width=1)
for sid, name in reference.STATUSES.items():
markup.add(types.InlineKeyboardButton(name, callback_data=f"ss:{task_number}:{sid}"))
markup.add(types.InlineKeyboardButton("✖ Отмена", callback_data="del"))
try: try:
bot.send_message( bot.edit_message_reply_markup(
call.message.chat.id, chat_id, msg_id,
f"Заявка *\\#{task_number}* — выберите новый статус:", reply_markup=_make_status_keyboard(task_number, group_id),
parse_mode="Markdown",
reply_markup=markup,
reply_to_message_id=call.message.message_id,
) )
bot.answer_callback_query(call.id) bot.answer_callback_query(call.id)
except Exception as e: except Exception as e:
bot.answer_callback_query(call.id, str(e), show_alert=True) if _is_stale_error(e):
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
else:
bot.answer_callback_query(call.id, str(e), show_alert=True)
@bot.callback_query_handler(func=lambda c: c.data.startswith("ss:")) @bot.callback_query_handler(func=lambda c: c.data.startswith("ss:"))
def cb_set_status(call): def cb_set_status(call):
"""Применяет выбранный статус через API.""" # ss:{task_number}:{group_id}:{status_id}
_, task_number, status_id = call.data.split(":") parts = call.data.split(":")
status_name = reference.STATUSES.get(int(status_id), f"#{status_id}") task_number = parts[1]
group_id = int(parts[2])
status_id = int(parts[3])
status_name = reference.STATUSES.get(status_id, f"#{status_id}")
success = intradesk_api.change_status(int(task_number), int(status_id)) success = intradesk_api.change_status(int(task_number), status_id)
if success: if success:
bot.answer_callback_query(call.id, f"✅ Статус → {status_name}") bot.answer_callback_query(call.id, f"✅ Статус → {status_name}")
_delete_message(call.message.chat.id, call.message.message_id) _restore_keyboard(call, task_number, group_id)
else: else:
bot.answer_callback_query(call.id, "❌ Ошибка при изменении статуса", show_alert=True) bot.answer_callback_query(call.id, "❌ Ошибка изменения статуса", show_alert=True)
# ── Callbacks: назначение исполнителя ───────────────────────────────────────── # ── Callbacks: назначение исполнителя ─────────────────────────────────────────
@bot.callback_query_handler(func=lambda c: c.data.startswith("ex:")) @bot.callback_query_handler(func=lambda c: c.data.startswith("ex:"))
def cb_show_executors(call): def cb_show_executors(call):
"""Показывает список сотрудников для назначения исполнителем.""" task_number, group_id = _parse2(call.data[3:])
task_number = call.data[3:] chat_id, msg_id = _current_msg(task_number, call)
employees = database.get_all_ref("employees")
if not employees:
bot.answer_callback_query(
call.id,
"Список сотрудников не загружен. Обновите справочники.",
show_alert=True,
)
return
markup = types.InlineKeyboardMarkup(row_width=1)
for emp in employees[:20]: # Telegram ограничивает кол-во кнопок
markup.add(types.InlineKeyboardButton(
emp["name"],
callback_data=f"se:{task_number}:{emp['id']}",
))
markup.add(types.InlineKeyboardButton("✖ Отмена", callback_data="del"))
try: try:
bot.send_message( bot.edit_message_reply_markup(
call.message.chat.id, chat_id, msg_id,
f"Заявка *\\#{task_number}* — выберите исполнителя:", reply_markup=_make_executor_keyboard(task_number, group_id),
parse_mode="Markdown",
reply_markup=markup,
reply_to_message_id=call.message.message_id,
) )
bot.answer_callback_query(call.id) bot.answer_callback_query(call.id)
except Exception as e: except Exception as e:
bot.answer_callback_query(call.id, str(e), show_alert=True) if _is_stale_error(e):
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
else:
bot.answer_callback_query(call.id, str(e), show_alert=True)
@bot.callback_query_handler(func=lambda c: c.data.startswith("se:")) @bot.callback_query_handler(func=lambda c: c.data.startswith("se:"))
def cb_set_executor(call): def cb_set_executor(call):
"""Назначает выбранного сотрудника исполнителем через API.""" # se:{task_number}:{group_id}:{emp_id}
_, task_number, emp_id = call.data.split(":") parts = call.data.split(":")
emp_name = database.resolve("employees", int(emp_id)) or f"#{emp_id}" task_number = parts[1]
group_id = int(parts[2])
emp_id = int(parts[3])
emp_name = database.resolve("employees", emp_id) or f"ID {emp_id}"
success = intradesk_api.assign_executor(int(task_number), user_id=int(emp_id)) # Сохраняем текущую группу, меняем только исполнителя
success = intradesk_api.assign_executor(
int(task_number), user_id=emp_id, group_id=group_id or None
)
if success: if success:
bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}") bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}")
_delete_message(call.message.chat.id, call.message.message_id) _restore_keyboard(call, task_number, group_id)
else: else:
bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True) bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True)
# ── Общий callback: удалить меню ────────────────────────────────────────────── @bot.callback_query_handler(func=lambda c: c.data.startswith("su:"))
def cb_unset_executor(call):
"""Убрать конкретного исполнителя, оставить только группу."""
task_number, group_id = _parse2(call.data[3:])
if not group_id:
bot.answer_callback_query(call.id, "Группа не определена", show_alert=True)
return
@bot.callback_query_handler(func=lambda c: c.data == "del") success = intradesk_api.assign_executor(int(task_number), group_id=group_id)
def cb_delete(call): if success:
bot.answer_callback_query(call.id, "✅ Исполнитель снят, группа сохранена")
_restore_keyboard(call, task_number, group_id)
else:
bot.answer_callback_query(call.id, "❌ Ошибка", show_alert=True)
# ── Callbacks: "Взять в работу" / "Снять меня" ────────────────────────────────
@bot.callback_query_handler(func=lambda c: c.data.startswith("take:"))
def cb_take(call):
task_number = call.data[5:]
task_id = database.get_task_id_by_number(task_number)
if not task_id:
bot.answer_callback_query(call.id, "Заявка не найдена", show_alert=True)
return
user_id = call.from_user.id
user_name = (call.from_user.full_name or call.from_user.username or str(user_id)).strip()
database.add_responsible(task_id, user_id, user_name)
bot.answer_callback_query(call.id, f"🙋 Вы взяли заявку #{task_number} в работу")
_refresh_responsible(call, task_id, task_number)
@bot.callback_query_handler(func=lambda c: c.data.startswith("drop:"))
def cb_drop(call):
task_number = call.data[5:]
task_id = database.get_task_id_by_number(task_number)
if not task_id:
bot.answer_callback_query(call.id, "Заявка не найдена", show_alert=True)
return
database.remove_responsible(task_id, call.from_user.id)
bot.answer_callback_query(call.id, f"✅ Вы сняты с заявки #{task_number}")
_refresh_responsible(call, task_id, task_number)
# ── Callback: назад (отмена выбора) ──────────────────────────────────────────
@bot.callback_query_handler(func=lambda c: c.data.startswith("back:"))
def cb_back(call):
task_number, group_id = _parse2(call.data[5:])
bot.answer_callback_query(call.id) bot.answer_callback_query(call.id)
_delete_message(call.message.chat.id, call.message.message_id) _restore_keyboard(call, task_number, group_id)
# ── Вспомогательные функции ───────────────────────────────────────────────────
def _parse2(s: str) -> tuple[str, int]:
"""Парсит '{task_number}:{group_id}' → (task_number, group_id)."""
parts = s.split(":", 1)
return parts[0], int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
def _current_msg(task_number: str, call) -> tuple[int, int]:
"""
Возвращает (chat_id, message_id) актуального поста из БД.
Если поста нет в БД использует данные из callback (может быть устаревшим).
"""
task_id = database.get_task_id_by_number(task_number)
if task_id:
data = database.get_message_data(task_id)
if data:
return config.CHANNEL_ID, data["message_id"]
return call.message.chat.id, call.message.message_id
def _is_stale_error(e: Exception) -> bool:
"""Возвращает True если ошибка означает что сообщение уже удалено или не изменилось."""
s = str(e).lower()
return any(x in s for x in (
"message to edit not found",
"message is not modified",
"message can't be edited",
"bad request",
))
def _restore_keyboard(call, task_number: str, group_id: int):
"""Восстанавливает основные кнопки на актуальном посте задачи."""
chat_id, msg_id = _current_msg(task_number, call)
try:
bot.edit_message_reply_markup(
chat_id, msg_id,
reply_markup=make_task_buttons(task_number, group_id),
)
except Exception as e:
if not _is_stale_error(e):
log.warning(f"restore_keyboard failed: {e}")
def _refresh_responsible(call, task_id: int, task_number: str):
"""Редактирует текст актуального поста из БД — обновляет список ответственных."""
data = database.get_message_data(task_id)
if not data or not data.get("base_text"):
return
responsible = database.get_responsible(task_id)
events_lines = database.get_stored_events(task_id)
full_text = _build_full_text(data["base_text"], responsible, events_lines, new_count=0)
markup = make_task_buttons(task_number, data.get("group_id") or 0)
try:
bot.edit_message_text(
full_text,
config.CHANNEL_ID,
data["message_id"],
parse_mode="Markdown",
disable_web_page_preview=True,
reply_markup=markup,
)
except ApiTelegramException as e:
if not _is_stale_error(e):
log.warning(f"_refresh_responsible failed: {e}")
def _delete_message(chat_id, message_id): def _delete_message(chat_id, message_id):
@ -179,8 +384,37 @@ def _delete_message(chat_id, message_id):
pass pass
# ── Гифка для заявок с ТО ─────────────────────────────────────────────────────
def send_gif_for_to(task_id: int):
"""
Отправляет гифку для заявки с ТО.
Предварительно удаляет предыдущую гифку если была.
Если GIF_URL пустой ничего не делает.
"""
if not config.GIF_URL:
return
# Удаляем старую гифку
old_gif_id = database.get_gif_message_id(task_id)
if old_gif_id:
_delete_message(config.CHANNEL_ID, old_gif_id)
database.delete_gif_message_id(task_id)
try:
msg = bot.send_animation(
config.CHANNEL_ID,
config.GIF_URL,
disable_notification=True,
)
database.save_gif_message_id(task_id, msg.message_id)
log.info(f"GIF sent for task {task_id}, message_id={msg.message_id}")
except Exception as e:
log.error(f"Failed to send GIF for task {task_id}: {e}")
# ── Запуск бота ─────────────────────────────────────────────────────────────── # ── Запуск бота ───────────────────────────────────────────────────────────────
def start_bot(): def start_bot():
log.info("Telegram bot started (polling)...") log.info("Telegram bot started (polling)...")
bot.infinity_polling() bot.infinity_polling(timeout=60)

View file

@ -1,9 +1,62 @@
"""
Конфигурация IntradBot.
Все параметры читаются из переменных окружения (.env через docker-compose).
При старте проверяются обязательные переменные если не заданы, падаем сразу.
"""
import os import os
import sys
TOKEN = os.environ.get("TELEGRAM_TOKEN", "")
API_KEY = os.environ.get("INTRADESK_API_KEY", "") def _require(name: str) -> str:
BASE_TASK_URL = os.environ.get("BASE_TASK_URL", "https://cleverparking.intradesk.ru/task/list/") """Читает переменную окружения. Если не задана — завершает процесс с понятной ошибкой."""
CHANNEL_ID = int(os.environ.get("CHANNEL_ID", "0")) value = os.environ.get(name, "").strip()
DB_FILE = os.environ.get("DB_FILE", "/app/data/intradbot.db") if not value:
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "") print(f"[FATAL] Обязательная переменная окружения не задана: {name}", file=sys.stderr)
PORT = int(os.environ.get("PORT", "8080")) sys.exit(1)
return value
def _optional(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def _int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (ValueError, TypeError):
return default
# ── Обязательные ──────────────────────────────────────────────────────────────
TOKEN = _require("TELEGRAM_TOKEN")
API_KEY = _require("INTRADESK_API_KEY")
CHANNEL_ID = _int("CHANNEL_ID", 0)
# ── Необязательные ────────────────────────────────────────────────────────────
BASE_TASK_URL = _optional("BASE_TASK_URL", "https://cleverparking.intradesk.ru/task/list/")
DB_FILE = _optional("DB_FILE", "/app/data/intradbot.db")
WEBHOOK_SECRET = _optional("WEBHOOK_SECRET")
PORT = _int("PORT", 8080)
PUBLIC_URL = _optional("PUBLIC_URL")
GIF_URL = _optional("GIF_URL", "https://media.tenor.com/OzN-0kxqXAgAAAAi/cat.gif")
# Пароль для дашборда (Basic Auth). Пустая строка — дашборд открыт.
DASHBOARD_PASSWORD = _optional("DASHBOARD_PASSWORD")
# Секрет для подписи ссылок Mini App
MINIAPP_SECRET = _optional("MINIAPP_SECRET", WEBHOOK_SECRET or TOKEN[:20])
# ── Бизнес-параметры (вынесены из кода) ──────────────────────────────────────
# ID сотрудников доступных для назначения исполнителем (через запятую в .env)
# Пример: EXECUTOR_USER_IDS=2227907,631481,525111
_executor_ids_raw = _optional("EXECUTOR_USER_IDS", "2227907,631481,525111")
EXECUTOR_USER_IDS: list[int] = [
int(x.strip()) for x in _executor_ids_raw.split(",") if x.strip().isdigit()
]
# Фильтр активных заявок для дашборда
# Пример: ACTIVE_TASKS_FILTER=(status ne 68045) and (executorgroup eq 243025)
ACTIVE_TASKS_FILTER = _optional(
"ACTIVE_TASKS_FILTER",
"(status ne 68045) and (executorgroup eq 243025)"
)

21978
data/bot.log Normal file

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 sqlite3
import threading
import config import config
_local = threading.local()
def _connect():
conn = sqlite3.connect(config.DB_FILE) def _connect() -> sqlite3.Connection:
conn.row_factory = sqlite3.Row """Возвращает соединение для текущего потока (создаёт при первом вызове)."""
conn = getattr(_local, "conn", None)
if conn is None:
conn = sqlite3.connect(config.DB_FILE, check_same_thread=False)
conn.row_factory = sqlite3.Row
_local.conn = conn
return conn return conn
@ -14,10 +29,21 @@ def init_db():
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
task_id INTEGER PRIMARY KEY, task_id INTEGER PRIMARY KEY,
message_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
task_number TEXT task_number TEXT,
group_id INTEGER DEFAULT 0,
base_text TEXT
) )
""") """)
# Универсальная таблица справочников: entity = 'status'|'priority'|'service'|... for col, dfn in [
("group_id", "INTEGER DEFAULT 0"),
("base_text", "TEXT"),
("events_json", "TEXT"),
]:
try:
conn.execute(f"ALTER TABLE messages ADD COLUMN {col} {dfn}")
except Exception:
pass
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS ref_data ( CREATE TABLE IF NOT EXISTS ref_data (
entity TEXT NOT NULL, entity TEXT NOT NULL,
@ -26,59 +52,177 @@ def init_db():
PRIMARY KEY (entity, id) PRIMARY KEY (entity, id)
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS responsible (
task_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
user_name TEXT NOT NULL,
PRIMARY KEY (task_id, user_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS gif_messages (
task_id INTEGER PRIMARY KEY,
message_id INTEGER NOT NULL
)
""")
try:
conn.execute("SELECT 1 FROM gif_messages LIMIT 1")
except Exception:
conn.execute("""
CREATE TABLE gif_messages (
task_id INTEGER PRIMARY KEY,
message_id INTEGER NOT NULL
)
""")
conn.commit() conn.commit()
# ── messages ────────────────────────────────────────────────────────────────── # ── messages ──────────────────────────────────────────────────────────────────
def get_message_id(task_id: int): def get_message_id(task_id: int):
with _connect() as conn: row = _connect().execute(
row = conn.execute( "SELECT message_id FROM messages WHERE task_id = ?", (task_id,)
"SELECT message_id FROM messages WHERE task_id = ?", (task_id,) ).fetchone()
).fetchone()
return row["message_id"] if row else None return row["message_id"] if row else None
def save_message_id(task_id: int, message_id: int, task_number: str = None): def get_message_data(task_id: int) -> dict | None:
with _connect() as conn: row = _connect().execute(
conn.execute( "SELECT message_id, task_number, group_id, base_text FROM messages WHERE task_id = ?",
"INSERT OR REPLACE INTO messages (task_id, message_id, task_number) VALUES (?, ?, ?)", (task_id,),
(task_id, message_id, task_number), ).fetchone()
) return dict(row) if row else None
conn.commit()
def get_task_id_by_number(task_number: str) -> int | None:
row = _connect().execute(
"SELECT task_id FROM messages WHERE task_number = ?", (str(task_number),)
).fetchone()
return row["task_id"] if row else None
def save_message_id(
task_id: int,
message_id: int,
task_number: str = None,
group_id: int = 0,
base_text: str = None,
):
conn = _connect()
row = conn.execute(
"SELECT events_json FROM messages WHERE task_id = ?", (task_id,)
).fetchone()
existing_events = row["events_json"] if row else None
conn.execute(
"""INSERT OR REPLACE INTO messages
(task_id, message_id, task_number, group_id, base_text, events_json)
VALUES (?, ?, ?, ?, ?, ?)""",
(task_id, message_id, task_number, group_id or 0, base_text, existing_events),
)
conn.commit()
def get_stored_events(task_id: int) -> list[str]:
row = _connect().execute(
"SELECT events_json FROM messages WHERE task_id = ?", (task_id,)
).fetchone()
if not row or not row["events_json"]:
return []
try:
return json.loads(row["events_json"])
except Exception:
return []
def save_stored_events(task_id: int, events: list[str]):
conn = _connect()
conn.execute(
"UPDATE messages SET events_json = ? WHERE task_id = ?",
(json.dumps(events, ensure_ascii=False), task_id),
)
conn.commit()
# ── ref_data ────────────────────────────────────────────────────────────────── # ── ref_data ──────────────────────────────────────────────────────────────────
def save_ref_data(entity: str, items: list[tuple[int, str]]): def save_ref_data(entity: str, items: list[tuple[int, str]]):
"""Перезаписывает весь справочник entity списком пар (id, name).""" conn = _connect()
with _connect() as conn: conn.executemany(
conn.execute("DELETE FROM ref_data WHERE entity = ?", (entity,)) "INSERT OR REPLACE INTO ref_data (entity, id, name) VALUES (?, ?, ?)",
conn.executemany( [(entity, item_id, name) for item_id, name in items],
"INSERT INTO ref_data (entity, id, name) VALUES (?, ?, ?)", )
[(entity, item_id, name) for item_id, name in items], conn.commit()
)
conn.commit()
def resolve(entity: str, item_id: int | None) -> str | None: def resolve(entity: str, item_id: int | None) -> str | None:
"""Возвращает name по entity + id. None если не найдено."""
if item_id is None: if item_id is None:
return None return None
with _connect() as conn: row = _connect().execute(
row = conn.execute( "SELECT name FROM ref_data WHERE entity = ? AND id = ?",
"SELECT name FROM ref_data WHERE entity = ? AND id = ?", (entity, item_id),
(entity, item_id), ).fetchone()
).fetchone()
return row["name"] if row else None return row["name"] if row else None
def get_all_ref(entity: str) -> list[dict]: def get_all_ref(entity: str) -> list[dict]:
"""Возвращает все записи справочника как список {id, name}.""" rows = _connect().execute(
with _connect() as conn: "SELECT id, name FROM ref_data WHERE entity = ? ORDER BY name",
rows = conn.execute( (entity,),
"SELECT id, name FROM ref_data WHERE entity = ? ORDER BY name", ).fetchall()
(entity,),
).fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows] return [{"id": r["id"], "name": r["name"]} for r in rows]
# ── responsible ───────────────────────────────────────────────────────────────
def add_responsible(task_id: int, user_id: int, user_name: str):
conn = _connect()
conn.execute(
"INSERT OR REPLACE INTO responsible (task_id, user_id, user_name) VALUES (?, ?, ?)",
(task_id, user_id, user_name),
)
conn.commit()
def remove_responsible(task_id: int, user_id: int):
conn = _connect()
conn.execute(
"DELETE FROM responsible WHERE task_id = ? AND user_id = ?",
(task_id, user_id),
)
conn.commit()
def get_responsible(task_id: int) -> list[dict]:
rows = _connect().execute(
"SELECT user_id, user_name FROM responsible WHERE task_id = ? ORDER BY rowid",
(task_id,),
).fetchall()
return [{"user_id": r["user_id"], "user_name": r["user_name"]} for r in rows]
# ── gif_messages ──────────────────────────────────────────────────────────────
def get_gif_message_id(task_id: int) -> int | None:
row = _connect().execute(
"SELECT message_id FROM gif_messages WHERE task_id = ?", (task_id,)
).fetchone()
return row["message_id"] if row else None
def save_gif_message_id(task_id: int, message_id: int):
conn = _connect()
conn.execute(
"INSERT OR REPLACE INTO gif_messages (task_id, message_id) VALUES (?, ?)",
(task_id, message_id),
)
conn.commit()
def delete_gif_message_id(task_id: int):
conn = _connect()
conn.execute("DELETE FROM gif_messages WHERE task_id = ?", (task_id,))
conn.commit()

View file

@ -6,4 +6,5 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: 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 logging
import os import os
import threading import threading
@ -31,9 +42,10 @@ def main():
reference.seed_static() reference.seed_static()
reference.load_all() reference.load_all()
threading.Thread(target=start_bot, daemon=True).start() threading.Thread(target=start_bot, daemon=True, name="telegram-bot").start()
threading.Thread(target=start_server, daemon=True).start() threading.Thread(target=start_server, daemon=True, name="web-server").start()
# Обновляем справочники каждый понедельник (новые сотрудники, группы и т.д.)
schedule.every().monday.at("10:00").do(reference.load_all) schedule.every().monday.at("10:00").do(reference.load_all)
log.info("IntradBot is running. Waiting for webhooks...") log.info("IntradBot is running. Waiting for webhooks...")

View file

@ -1,13 +1,25 @@
"""
Парсинг и форматирование данных из вебхуков и API Intradesk.
"""
import json import json
import logging import logging
import re
from dataclasses import dataclass
from typing import Literal
import config
import database import database
from intradesk import reference from intradesk import reference
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Маппинг blockname → entity в БД справочников # ── Маппинги ──────────────────────────────────────────────────────────────────
_ADDFIELD_BOOL_LABELS: dict[str, str] = {
"addfield_677b45atoBoolean": "ТО",
"addfield_ea5d305sozvon_pered_vyezdomBoolean": "Созвон перед выездом",
"addfield_2684bdczamena_oborudovaniiaBoolean": "Замена оборудования",
}
BLOCK_TO_ENTITY: dict[str, str] = { BLOCK_TO_ENTITY: dict[str, str] = {
"status": "statuses", "status": "statuses",
"priority": "priorities", "priority": "priorities",
@ -20,8 +32,7 @@ BLOCK_TO_ENTITY: dict[str, str] = {
"sla": "sla", "sla": "sla",
} }
# Человекочитаемые названия полей FIELD_LABELS: dict[str, str] = {
FIELD_LABELS = {
"status": "Статус", "status": "Статус",
"priority": "Приоритет", "priority": "Приоритет",
"executor": "Исполнитель", "executor": "Исполнитель",
@ -38,16 +49,51 @@ FIELD_LABELS = {
"tags": "Теги", "tags": "Теги",
} }
# Поля, изменение которых не выводится в уведомлении IGNORED_FIELDS: set[str] = {
IGNORED_FIELDS = {
"lifetime", "updatedat", "updatedby", "updatedchannel", "lifetime", "updatedat", "updatedby", "updatedchannel",
"reactiondatefact", "resolutiondatefact", "resolutionleftminutes", "reactiondatefact", "resolutiondatefact", "resolutionleftminutes",
"reactiondateplan", "ismassincidentactive", "reactiondateplan", "ismassincidentactive",
} }
_QUOTE_RE = re.compile(
r'\[quote[^\]]*\].*?\[/quote\]'
r'|\{quote[^\}]*\}.*?\{/quote\}'
r'|<blockquote[^>]*>.*?</blockquote>'
r'|<quote[^>]*>.*?</quote>',
re.DOTALL | re.IGNORECASE,
)
_FILE_EXT_RE = re.compile(r'\.([a-zA-Z0-9]{1,10})$')
# ── Типы событий ──────────────────────────────────────────────────────────────
@dataclass
class ParsedEvent:
"""Нормализованное событие из истории заявки."""
kind: Literal["comment", "change"]
text: str
# ── Утилиты ───────────────────────────────────────────────────────────────────
def _shorten_name(name: str | None) -> str | None:
"""'Иванов Иван Петрович''Иванов И.'"""
if not name:
return name
parts = name.strip().split()
if len(parts) >= 2:
return f"{parts[0]} {parts[1][0]}."
return name
def _strip_quote(text: str) -> tuple[str, bool]:
"""Убирает блоки цитирования. Возвращает (cleaned_text, had_quote)."""
cleaned, n = _QUOTE_RE.subn("", text)
return cleaned.strip(), n > 0
def _parse_json_field(raw): def _parse_json_field(raw):
"""Парсит JSON-строку или возвращает как есть."""
if raw is None: if raw is None:
return None return None
if isinstance(raw, (dict, list)): if isinstance(raw, (dict, list)):
@ -61,7 +107,6 @@ def _parse_json_field(raw):
def _scalar(raw): def _scalar(raw):
"""Возвращает скалярное значение поля Fields (Value или Id)."""
parsed = _parse_json_field(raw) parsed = _parse_json_field(raw)
if isinstance(parsed, dict): if isinstance(parsed, dict):
return parsed.get("Value") or parsed.get("Id") return parsed.get("Value") or parsed.get("Id")
@ -69,139 +114,170 @@ def _scalar(raw):
def _extract_name(v: dict | None, blockname: str = "") -> str | None: def _extract_name(v: dict | None, blockname: str = "") -> str | None:
"""
Достаёт человекочитаемое имя из объекта valuenew/valueold.
Обрабатывает три варианта поля name:
1. строка: {"id": 8452, "name": "Иванов"}
2. объект {ru, en}: {"id": 96, "name": {"ru": "Выполнена", "en": null}}
3. отсутствует/null: {"id": 68045, "name": null} резольвинг через БД
"""
if not v: if not v:
return None return None
raw_name = v.get("currentname") or v.get("name") or v.get("Name")
raw_name = v.get("currentname") or v.get("name")
# name — объект {"ru": ..., "en": ...}
if isinstance(raw_name, dict): if isinstance(raw_name, dict):
raw_name = raw_name.get("ru") or raw_name.get("en") or None raw_name = raw_name.get("ru") or raw_name.get("en") or None
if raw_name: if raw_name:
return str(raw_name).strip() or None name_str = str(raw_name).strip() or None
if name_str and blockname in ("executor", "initiator"):
# name отсутствует или null — резольвинг через БД/статик return _shorten_name(name_str)
item_id = v.get("id") return name_str
item_id = v.get("id") or v.get("Id")
entity = BLOCK_TO_ENTITY.get(blockname) entity = BLOCK_TO_ENTITY.get(blockname)
if item_id and entity: if item_id and entity:
return reference.resolve(entity, item_id) or str(item_id) resolved = reference.resolve(entity, item_id) or str(item_id)
if blockname in ("executor", "initiator"):
return _shorten_name(resolved)
return resolved
return str(item_id) if item_id else None return str(item_id) if item_id else None
def get_executor_group(fields: dict) -> str | None: # ── Ядро: разбор одного события ───────────────────────────────────────────────
"""Группа исполнителей (Type=10 в taskusergroups)."""
raw = _parse_json_field(fields.get("taskusergroups", {})) def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
if not isinstance(raw, dict): """
Разбирает одно сырое событие из lifetime в нормализованный ParsedEvent.
Используется как в parse_lifetime() (вебхук), так и в format_lifetime_api_events()
(прямой API). Единственный источник правды для логики разбора событий.
"""
etype = event.get("type")
blockname = event.get("blockname", "")
short_user = _shorten_name(username) or "Неизвестный"
# ── Комментарии ──────────────────────────────────────────────────────────
if etype in (50, 55):
text = event.get("stringvalue") or ""
if not text:
return None
text, had_quote = _strip_quote(text)
if not text:
return None
prefix = "Ответ на прошлое: " if had_quote else ""
lock = " 🔒" if etype == 55 else ""
return ParsedEvent("comment", f"*{short_user}*{lock}: {prefix}{text}")
if blockname in IGNORED_FIELDS:
return None return None
for group in raw.get("TaskUserGroups", []):
if group.get("Type") == 10: # ── Доп. поля Boolean (ТО, Созвон и т.д.) ────────────────────────────────
return group.get("Name", "").strip() or None addfield = event.get("additionalfieldvalue")
if addfield is None and blockname.startswith("addfield_"):
str_val_add = event.get("stringvalue")
if str_val_add is not None:
addfield = {"value": str_val_add}
if addfield is not None and blockname.startswith("addfield_"):
bool_label = _ADDFIELD_BOOL_LABELS.get(blockname)
if bool_label:
raw_new = addfield.get("value") if isinstance(addfield, dict) else addfield
raw_old = addfield.get("oldvalue") if isinstance(addfield, dict) else None
_bool_str = lambda v: "*да*" if str(v).lower() == "true" else "нет"
if raw_old is not None:
return ParsedEvent("change", f"{bool_label}: {_bool_str(raw_old)}{_bool_str(raw_new)}")
return ParsedEvent("change", f"{bool_label}: {_bool_str(raw_new)}")
return None # Неизвестное доп. поле — пропускаем
label = FIELD_LABELS.get(blockname, reference.event_name(etype))
# ── Изменения с entityvalue / entitylanguagevalue ─────────────────────────
entity_data = event.get("entityvalue") or event.get("entitylanguagevalue")
if entity_data:
val_new = entity_data.get("valuenew") or {}
val_old = entity_data.get("valueold")
new_str = _extract_name(val_new, blockname)
old_str = _extract_name(val_old, blockname) if val_old else None
if not new_str and not old_str:
return None
if new_str and old_str:
return ParsedEvent("change", f"{label}: {old_str} → *{new_str}*")
if new_str:
return ParsedEvent("change", f"{label}: *{new_str}*")
return ParsedEvent("change", f"{label}: {old_str} → *снят*")
# ── Строковые изменения ───────────────────────────────────────────────────
str_val = event.get("stringvalue")
if str_val:
if blockname in ("file", "files", "taskfiles", "attachment", "attachments"):
attach = _parse_json_field(str_val)
if isinstance(attach, dict):
fname = attach.get("Name", "")
uploader = _shorten_name(attach.get("UploadedBy", "").strip() or None) or "?"
return ParsedEvent("change", f"• 📎 {uploader} прикрепил: {fname}")
m = _FILE_EXT_RE.search(str_val.strip())
ext = m.group(1).upper() if m else "файл"
return ParsedEvent("change", f"• 📎 Добавлен файл .{ext}")
truncated = str_val[:80] + ("" if len(str_val) > 80 else "")
return ParsedEvent("change", f"{label}: *{truncated}*")
# ── Событие без значения ──────────────────────────────────────────────────
if etype not in (185, 250):
return ParsedEvent("change", f"{label}")
return None return None
def get_initiator(fields: dict) -> str | None: # ── Публичные функции ─────────────────────────────────────────────────────────
"""Инициатор заявки (Type=10 в taskusers)."""
raw = _parse_json_field(fields.get("taskusers", {})) def get_description_event(fields: dict) -> str | None:
if not isinstance(raw, dict): description = _scalar(fields.get("description"))
if not description:
return None return None
for user in raw.get("TaskUsers", []): description = re.sub(r'<[^>]+>', ' ', description).strip()
if user.get("Type") == 10: description = re.sub(r'\s+', ' ', description).strip()
return user.get("Name", "").strip() or None if not description:
return None return None
truncated = description[:100] + ('' if len(description) > 100 else '')
return f"📝 {truncated}"
def is_final(fields: dict) -> bool: def parse_lifetime(fields: dict, max_events: int = 5) -> tuple[list[str], list[str]]:
"""Возвращает True если статус задачи финальный (закрыта)."""
raw = _parse_json_field(fields.get("status", {}))
if isinstance(raw, dict):
return bool(raw.get("IsFinal", False))
return False
def parse_lifetime(fields: dict) -> tuple[list[str], list[str]]:
""" """
Разбирает lifetime-поле вебхука. Разбирает lifetime-поле вебхука (вложенная структура).
Возвращает (changes, comments) последние max_events событий.
Возвращает:
changes строки вида « Поле: было стало»
comments строки вида «*Автор*: текст»
""" """
changes: list[str] = [] all_events: list[ParsedEvent] = []
comments: list[str] = []
lifetime = _parse_json_field(fields.get("lifetime", "{}")) lifetime = _parse_json_field(fields.get("lifetime", "{}"))
if not isinstance(lifetime, dict): if not isinstance(lifetime, dict):
return changes, comments return [], []
for entry in lifetime.get("Data", []): for entry in lifetime.get("Data", []):
if entry.get("lifetimetype") == 10:
continue
username = entry.get("username", "Неизвестный") username = entry.get("username", "Неизвестный")
for event in entry.get("events", {}).get("Data", []): for event in entry.get("events", {}).get("Data", []):
etype = event.get("type") parsed = _parse_single_event(event, username)
blockname = event.get("blockname", "") if parsed:
all_events.append(parsed)
# ── Комментарии ────────────────────────────────────────────────
if etype == 50:
text = event.get("stringvalue")
if text:
comments.append(f"*{username}*: {text}")
continue
# Приватный комментарий (необязательно показывать, но пусть будет)
if etype == 55:
text = event.get("stringvalue")
if text:
comments.append(f"*{username}* 🔒: {text}")
continue
if blockname in IGNORED_FIELDS:
continue
label = FIELD_LABELS.get(blockname, reference.event_name(etype))
# ── Изменения с entityvalue / entitylanguagevalue ──────────────
entity_data = event.get("entityvalue") or event.get("entitylanguagevalue")
if entity_data:
val_new = entity_data.get("valuenew") or {}
val_old = entity_data.get("valueold")
new_str = _extract_name(val_new, blockname)
old_str = _extract_name(val_old, blockname) if val_old else None
if not new_str:
continue
if old_str:
changes.append(f"{label}: {old_str} → *{new_str}*")
else:
changes.append(f"{label}: *{new_str}*")
continue
# ── Строковые изменения (название, описание) ───────────────────
str_val = event.get("stringvalue")
if str_val:
changes.append(f"{label}: *{str_val[:80]}{'' if len(str_val) > 80 else ''}*")
continue
# ── Событие без значения (теги, файлы, эскалация) ─────────────
if etype not in (185, 250): # пропускаем служебные
changes.append(f"{label}")
recent = all_events[-max_events:] if max_events and len(all_events) > max_events else all_events
changes = [e.text for e in recent if e.kind == "change"]
comments = [e.text for e in recent if e.kind == "comment"]
return changes, comments return changes, comments
def format_lifetime_api_events(events_list: list[dict], max_events: int = 5) -> str:
"""
Форматирует события из прямого API lifetime (плоский список).
events_list результат intradesk_api.get_task_lifetime().
"""
lines: list[str] = []
for event in events_list[:max_events]:
username = event.get("username", "")
parsed = _parse_single_event(event, username)
if parsed:
# Для API-формата обрезаем комментарии до 100 символов
text = parsed.text
if parsed.kind == "comment" and len(text) > 110:
text = text[:110] + ""
lines.append(text)
return "\n".join(lines)
def _resolve_field(fields: dict, field: str, entity: str) -> str | None: def _resolve_field(fields: dict, field: str, entity: str) -> str | None:
"""Резолвит ID поля верхнего уровня через справочники."""
raw = _parse_json_field(fields.get(field, {})) raw = _parse_json_field(fields.get(field, {}))
if not isinstance(raw, dict): if not isinstance(raw, dict):
return None return None
@ -211,60 +287,112 @@ def _resolve_field(fields: dict, field: str, entity: str) -> str | None:
return reference.resolve(entity, item_id) or str(item_id) return reference.resolve(entity, item_id) or str(item_id)
def get_object_name(fields: dict) -> str | None:
raw = _parse_json_field(fields.get("taskusergroups", {}))
if not isinstance(raw, dict):
return None
for group in raw.get("TaskUserGroups", []):
if group.get("Type") == 50:
return group.get("Name", "").strip() or None
return None
def get_executor_group(fields: dict) -> str | None:
raw = _parse_json_field(fields.get("taskusergroups", {}))
if not isinstance(raw, dict):
return None
for group in raw.get("TaskUserGroups", []):
if group.get("Type") == 10:
return group.get("Name", "").strip() or None
return None
def get_executor_group_id(fields: dict) -> int:
raw = _parse_json_field(fields.get("taskusergroups", {}))
if not isinstance(raw, dict):
return 0
for group in raw.get("TaskUserGroups", []):
if group.get("Type") == 10:
return int(group.get("Id", 0))
return 0
def get_initiator(fields: dict) -> str | None:
raw = _parse_json_field(fields.get("taskusers", {}))
if not isinstance(raw, dict):
return None
for user in raw.get("TaskUsers", []):
if user.get("Type") == 10:
return _shorten_name((user.get("Name") or "").strip() or None)
return None
def get_executor(fields: dict) -> str | None:
raw = _parse_json_field(fields.get("taskusers", {}))
if not isinstance(raw, dict):
return None
for user in raw.get("TaskUsers", []):
if user.get("Type") == 30:
return _shorten_name((user.get("Name") or "").strip() or None)
return None
def is_final(fields: dict) -> bool:
raw = _parse_json_field(fields.get("status", {}))
if isinstance(raw, dict):
return bool(raw.get("IsFinal", False))
return False
def is_to(fields: dict) -> bool:
raw = _parse_json_field(fields.get("addfield_677b45atoBoolean"))
if isinstance(raw, dict):
val = raw.get("Value")
return str(val).lower() == "true" if val is not None else False
return False
def get_all_executors(fields: dict) -> list[str]:
result: list[str] = []
seen: set[str] = set()
raw_users = _parse_json_field(fields.get("taskusers", {}))
if isinstance(raw_users, dict):
for user in raw_users.get("TaskUsers", []):
if user.get("Type") == 30:
name = _shorten_name((user.get("Name") or "").strip() or None)
if name and name not in seen:
seen.add(name)
result.append(name)
group = get_executor_group(fields)
if group and group not in seen:
result.append(group)
return result
def format_notification(payload: dict) -> str: def format_notification(payload: dict) -> str:
"""Полная карточка задачи для основного поста в канале.""" """Полная карточка задачи для основного поста в канале."""
op_type = payload.get("Event", {}).get("OperationType", "") op_type = payload.get("Event", {}).get("OperationType", "")
fields = payload.get("Fields", {}) fields = payload.get("Fields", {})
number = fields.get("Number", "?")
number = fields.get("Number", "?") name = _scalar(fields.get("name")) or ""
name = _scalar(fields.get("name")) or "" applicant = get_initiator(fields)
executor_grp = get_executor_group(fields) executors = get_all_executors(fields)
initiator = get_initiator(fields) obj_name = get_object_name(fields)
to_flag = is_to(fields)
status = _resolve_field(fields, "status", "statuses") status = _resolve_field(fields, "status", "statuses")
priority = _resolve_field(fields, "priority", "priorities")
service = _resolve_field(fields, "service", "services")
tasktype = _resolve_field(fields, "tasktype", "tasktypes")
changes, comments = parse_lifetime(fields)
header = f"🆕 *Новая заявка \\#{number}*" if op_type == "Created" \
else f"🔄 *Заявка \\#{number} обновлена*"
header = f"🆕 *Новая #{number}*" if op_type == "Created" \
else f"🔄 *#{number} обновлена*"
lines = [header, name, ""] lines = [header, name, ""]
if to_flag: lines.append("🔧 *ТО*")
if status: if status: lines.append(f"📌 Статус: {status}")
lines.append(f"📌 Статус: {status}") if obj_name: lines.append(f"🏢 Объект: {obj_name}")
if priority: if applicant: lines.append(f"👤 Автор: {applicant}")
lines.append(f"🔺 Приоритет: {priority}") if executors: lines.append(f"👔 Исполнитель: {', '.join(executors)}")
if service:
lines.append(f"🗂 Услуга: {service}")
if tasktype:
lines.append(f"📋 Тип: {tasktype}")
if executor_grp:
lines.append(f"👥 Группа: {executor_grp}")
if initiator:
lines.append(f"👤 Инициатор: {initiator}")
if any([status, priority, service, tasktype, executor_grp, initiator]):
lines.append("")
if changes:
lines.extend(changes)
lines.append("")
if comments:
lines.append("💬 *Комментарии:*")
lines.extend(comments)
lines.append("")
task_url = config.BASE_TASK_URL + str(number)
lines.append(f"[Открыть заявку]({task_url})")
return "\n".join(lines) return "\n".join(lines)
def format_update_summary(changes: list[str], comments: list[str]) -> str | None: def format_update_summary(changes: list[str], comments: list[str]) -> str | None:
"""Короткий reply-пост при обновлении (триггерит пуш подписчикам канала)."""
if not changes and not comments: if not changes and not comments:
return None return None
lines = ["🔄 *Изменено:*"] lines = ["🔄 *Изменено:*"]

View file

@ -1,8 +1,9 @@
import logging import logging
from datetime import datetime from datetime import datetime
import database
import events import events
from bot import telegram as bot from bot import telegram as tg
from intradesk import formatter from intradesk import formatter
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -24,7 +25,20 @@ def handle_webhook(payload: dict):
return return
changes, comments = formatter.parse_lifetime(fields) changes, comments = formatter.parse_lifetime(fields)
full_text = formatter.format_notification(payload) base_text = formatter.format_notification(payload)
group_id = formatter.get_executor_group_id(fields)
closed = formatter.is_final(fields)
is_to_now = formatter.is_to(fields)
# Формируем плоский список новых строк событий для накопления в посте
new_event_lines = changes + [f"💬 {c}" for c in comments]
# Если заявка новая в БД — добавляем описание первым событием
is_new_task = not database.get_message_data(task_id)
if is_new_task:
desc_event = formatter.get_description_event(fields)
if desc_event:
new_event_lines = [desc_event] + new_event_lines
events.broadcast({ events.broadcast({
"type": "webhook", "type": "webhook",
@ -37,16 +51,18 @@ def handle_webhook(payload: dict):
"changes": changes, "changes": changes,
"comments": comments, "comments": comments,
}, },
"formatted": full_text, "formatted": base_text,
}) })
closed = formatter.is_final(fields) tg.recreate_post(
task_id, str(number), base_text, group_id,
closed=closed, new_event_lines=new_event_lines,
)
if op_type == "Created": # ── Гифка для ТО ──────────────────────────────────────────────────────
bot.send_or_edit(task_id, number, full_text, closed=closed) # Отправляем (и удаляем предыдущую) при любом вебхуке если ТО = да
else: if is_to_now:
summary = formatter.format_update_summary(changes, comments) tg.send_gif_for_to(task_id)
bot.send_or_edit(task_id, number, full_text, summary, closed=closed)
except Exception: except Exception:
log.exception("Error while handling webhook") log.exception("Error while handling webhook")

View file

@ -101,88 +101,22 @@ EVENT_TYPES: dict[int, str] = {
400: "Эскалация заявки", 400: "Эскалация заявки",
} }
# ── API-справочники (загружаются из Intradesk) ───────────────────────────────── # ── API-справочники (загружаются из Intradesk) ─────────────────────────────────
#
# Статус эндпоинта:
# [OK] — проверено, работает
# [?] — нужно проверить, путь предположительный
SETTINGS_URL = "https://apigw.intradesk.ru/settings/odata" _TASKS_URL = "https://apigw.intradesk.ru/tasklist/odata/v3/tasks"
ENTITIES = [ # Маппинг поля "type" из dictionaries → ключ entity в БД
{ _DICT_TYPE_TO_ENTITY: dict[str, str] = {
"key": "employees", "user": "employees",
"url": f"{SETTINGS_URL}/Employees", "usergroup": "groups",
"params": {"$filter": "isArchived eq false"}, "status": "statuses",
"id_field": "id", "priority": "priorities",
"name_field": "name", "tasktype": "tasktypes",
"status": "OK", "tags": "tags",
}, "workflow": "workflows",
{ "asset": "assets",
"key": "groups", }
"url": f"{SETTINGS_URL}/UserGroups",
"params": {"$filter": "isArchived eq false"},
"id_field": "id",
"name_field": "name",
"status": "?",
},
{
"key": "statuses",
"url": f"{SETTINGS_URL}/Statuses",
"params": {},
"id_field": "id",
"name_field": "name",
"status": "?",
},
{
"key": "priorities",
"url": f"{SETTINGS_URL}/Priorities",
"params": {},
"id_field": "id",
"name_field": "name",
"status": "?",
},
{
"key": "services",
"url": f"{SETTINGS_URL}/ServiceCatalog",
"params": {"$filter": "isArchived eq false"},
"id_field": "id",
"name_field": "name",
"status": "?",
},
{
"key": "tasktypes",
"url": f"{SETTINGS_URL}/TaskTypes",
"params": {"$filter": "isArchived eq false"},
"id_field": "id",
"name_field": "name",
"status": "?",
},
{
"key": "tags",
"url": f"{SETTINGS_URL}/Tags",
"params": {},
"id_field": "id",
"name_field": "name",
"status": "?",
},
{
"key": "workflows",
"url": f"{SETTINGS_URL}/Workflows",
"params": {"$filter": "isArchived eq false"},
"id_field": "id",
"name_field": "name",
"status": "?",
},
{
"key": "sla",
"url": f"{SETTINGS_URL}/Sla",
"params": {},
"id_field": "id",
"name_field": "name",
"status": "?",
},
]
# ── вспомогательные ──────────────────────────────────────────────────────────── # ── вспомогательные ────────────────────────────────────────────────────────────
@ -196,12 +130,10 @@ def resolve(entity: str, item_id: int | None) -> str | None:
if item_id is None: if item_id is None:
return None return None
# БД — приоритет
name = database.resolve(entity, item_id) name = database.resolve(entity, item_id)
if name: if name:
return name return name
# Fallback на статические константы
_fallbacks = { _fallbacks = {
"statuses": STATUSES, "statuses": STATUSES,
"priorities": PRIORITIES, "priorities": PRIORITIES,
@ -222,47 +154,6 @@ def user_type_name(user_type: int) -> str:
# ── загрузка из API ──────────────────────────────────────────────────────────── # ── загрузка из API ────────────────────────────────────────────────────────────
def _fetch_all_pages(url: str, params: dict) -> list[dict]:
params = {**params, "ApiKey": config.API_KEY}
items = []
current_url = url
while current_url:
try:
resp = requests.get(current_url, params=params, timeout=30)
resp.raise_for_status()
except requests.exceptions.RequestException as e:
log.error(f"Request failed [{current_url}]: {e}")
break
data = resp.json()
items.extend(data.get("value", []))
current_url = data.get("@odata.nextLink")
params = None
return items
def _load_entity(entity: dict) -> int:
key = entity["key"]
id_field = entity["id_field"]
name_field = entity["name_field"]
items = _fetch_all_pages(entity["url"], entity.get("params", {}))
if not items:
log.warning(f"[{key}] empty or failed — skipped")
return 0
pairs = [
(item[id_field], str(item.get(name_field) or "").strip())
for item in items
if id_field in item
]
database.save_ref_data(key, pairs)
log.info(f"[{key}] loaded {len(pairs)} records")
return len(pairs)
def seed_static(): def seed_static():
"""Сидирует в БД известные статические данные (статусы, приоритеты).""" """Сидирует в БД известные статические данные (статусы, приоритеты)."""
database.save_ref_data("statuses", list(STATUSES.items())) database.save_ref_data("statuses", list(STATUSES.items()))
@ -271,8 +162,45 @@ def seed_static():
def load_all(): def load_all():
"""Загружает все справочники из API. Неудачные — логирует и пропускает.""" """
log.info("Loading reference data from Intradesk API...") Загружает справочники из поля dictionaries ответа tasklist API.
for entity in ENTITIES: Один запрос заменяет 7+ отдельных запросов к settings/odata.
_load_entity(entity) Обновление накопительное: новые/обновлённые записи добавляются,
старые не удаляются (они могут встретиться в старых заявках).
"""
log.info("Loading reference data from tasklist dictionaries...")
try:
resp = requests.get(
_TASKS_URL,
params={"ApiKey": config.API_KEY, "$top": 1},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except requests.exceptions.RequestException as e:
log.error(f"Failed to fetch tasklist for dictionaries: {e}")
return
dictionaries = data.get("dictionaries", [])
if not dictionaries:
log.warning("dictionaries field is empty or missing in tasklist response")
return
# Группируем по entity
buckets: dict[str, list[tuple[int, str]]] = {}
for item in dictionaries:
dtype = item.get("type", "")
entity = _DICT_TYPE_TO_ENTITY.get(dtype)
if not entity:
continue
item_id = item.get("id")
name = str(item.get("name") or "").strip()
if item_id and name:
buckets.setdefault(entity, []).append((item_id, name))
for entity, pairs in buckets.items():
database.save_ref_data(entity, pairs)
log.info(f"[{entity}] updated {len(pairs)} records from dictionaries")
log.info("Reference data loading complete.") log.info("Reference data loading complete.")

View file

@ -1,4 +1,5 @@
requests requests==2.32.3
pytelegrambotapi pytelegrambotapi==4.14.1
flask flask==3.1.0
schedule schedule==1.2.2
gunicorn==23.0.0

View file

@ -3,7 +3,11 @@ import logging
import os import os
import threading import threading
from flask import Flask, Response, jsonify, render_template, request, stream_with_context from flask import Flask, Response, jsonify, render_template, request, stream_with_context, abort
import hashlib
import hmac
import json as _json
from functools import wraps
import config import config
import database import database
@ -16,6 +20,69 @@ log = logging.getLogger(__name__)
app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates")) app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"))
# ── Авторизация ────────────────────────────────────────────────────────────────
def check_basic_auth(username, password):
"""Проверяет Basic Auth. Логин фиксированный 'admin'."""
if not config.DASHBOARD_PASSWORD:
return True # Пароль не задан — всё открыто
return username == "admin" and password == config.DASHBOARD_PASSWORD
def require_basic_auth(f):
"""Декоратор: требует Basic Auth если DASHBOARD_PASSWORD задан."""
@wraps(f)
def decorated(*args, **kwargs):
if not config.DASHBOARD_PASSWORD:
return f(*args, **kwargs)
auth = request.authorization
if not auth or not check_basic_auth(auth.username, auth.password):
return Response(
"Требуется авторизация",
401,
{"WWW-Authenticate": 'Basic realm="IntradBot Dashboard"'},
)
return f(*args, **kwargs)
return decorated
def verify_task_token(task_number: str, exp: str, sig: str) -> bool:
"""Проверяет HMAC-подпись токена из URL Mini App."""
if not config.DASHBOARD_PASSWORD:
return True # Защита отключена
if not exp or not sig:
return False
try:
import time as _time
if int(exp) < int(_time.time()):
log.warning(f"Token expired for task {task_number}")
return False
msg = f"{task_number}:{exp}".encode()
expected = hmac.new(config.MINIAPP_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:16]
return hmac.compare_digest(expected, sig)
except Exception as e:
log.warning(f"Token verification error: {e}")
return False
def require_task_token(f):
"""Декоратор: проверяет подписанный токен из URL для API Mini App."""
@wraps(f)
def decorated(*args, **kwargs):
if not config.DASHBOARD_PASSWORD:
return f(*args, **kwargs)
# Берём номер задачи из любого параметра запроса
task = (request.args.get("number")
or request.args.get("task_id")
or kwargs.get("file_id", ""))
exp = request.args.get("exp", "")
sig = request.args.get("sig", "")
if not verify_task_token(str(task), exp, sig):
return jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs)
return decorated
# ── Webhook от Intradesk ─────────────────────────────────────────────────────── # ── Webhook от Intradesk ───────────────────────────────────────────────────────
@app.route("/webhook", methods=["POST"]) @app.route("/webhook", methods=["POST"])
@ -42,11 +109,18 @@ def health():
# ── Дашборд ──────────────────────────────────────────────────────────────────── # ── Дашборд ────────────────────────────────────────────────────────────────────
@app.route("/") @app.route("/")
@require_basic_auth
def dashboard(): def dashboard():
return render_template("dashboard.html") return render_template("dashboard.html")
@app.route("/miniapp")
def miniapp():
return render_template("miniapp.html")
@app.route("/stream") @app.route("/stream")
@require_basic_auth
def stream(): def stream():
q = events.subscribe() q = events.subscribe()
@ -71,6 +145,7 @@ def stream():
# ── API заявок ───────────────────────────────────────────────────────────────── # ── API заявок ─────────────────────────────────────────────────────────────────
@app.route("/api/tasks/search") @app.route("/api/tasks/search")
@require_task_token
def api_tasks_search(): def api_tasks_search():
try: try:
number = int(request.args.get("number", 0)) number = int(request.args.get("number", 0))
@ -83,15 +158,17 @@ def api_tasks_search():
@app.route("/api/tasks/active") @app.route("/api/tasks/active")
@require_task_token
def api_tasks_active(): def api_tasks_active():
filter_expr = request.args.get( filter_expr = request.args.get(
"filter", "(status ne 68045) and (executorgroup eq 243025)" "filter", config.ACTIVE_TASKS_FILTER
) )
tasks = intradesk_api.get_tasks(filter_expr, top=50) tasks = intradesk_api.get_tasks(filter_expr, top=50)
return jsonify(tasks) return jsonify(tasks)
@app.route("/api/task/lifetime") @app.route("/api/task/lifetime")
@require_task_token
def api_task_lifetime(): def api_task_lifetime():
try: try:
task_id = int(request.args.get("task_id", 0)) task_id = int(request.args.get("task_id", 0))
@ -140,14 +217,40 @@ def api_task_execute():
return jsonify({"success": success}) return jsonify({"success": success})
# ── Прокси для файлов Intradesk (скрывает API ключ) ───────────────────────────
@app.route("/api/files/<file_id>")
def api_file(file_id):
"""Проксирует файл из Intradesk, подставляя API ключ на сервере."""
import requests as req
thumb = request.args.get("thumb") == "1"
url = f"https://apigw.intradesk.ru/files/api/tasks/files/{file_id}"
if thumb:
url += "/thumbnail"
try:
r = req.get(url, params={"ApiKey": config.API_KEY}, timeout=30, stream=True)
r.raise_for_status()
content_type = r.headers.get("Content-Type", "application/octet-stream")
return Response(r.content, content_type=content_type)
except Exception as e:
log.error(f"File proxy error for {file_id}: {e}")
return jsonify({"error": "Файл недоступен"}), 502
def create_app():
"""Фабрика приложения — используется Gunicorn и тестами."""
return app
def start_server(): def start_server():
"""Запуск через встроенный Flask — только для локальной разработки."""
ssl_cert = os.path.join("/app/ssl", "cert.pem") ssl_cert = os.path.join("/app/ssl", "cert.pem")
ssl_key = os.path.join("/app/ssl", "key.pem") ssl_key = os.path.join("/app/ssl", "key.pem")
if os.path.exists(ssl_cert) and os.path.exists(ssl_key): if os.path.exists(ssl_cert) and os.path.exists(ssl_key):
log.info(f"Web server + dashboard → https://0.0.0.0:{config.PORT} (SSL)") log.info(f"Web server → https://0.0.0.0:{config.PORT} (SSL, dev mode)")
app.run(host="0.0.0.0", port=config.PORT, app.run(host="0.0.0.0", port=config.PORT,
ssl_context=(ssl_cert, ssl_key), use_reloader=False) ssl_context=(ssl_cert, ssl_key), use_reloader=False)
else: else:
log.info(f"Web server + dashboard → http://0.0.0.0:{config.PORT} (no SSL)") log.info(f"Web server → http://0.0.0.0:{config.PORT} (dev mode, no SSL)")
app.run(host="0.0.0.0", port=config.PORT, use_reloader=False) app.run(host="0.0.0.0", port=config.PORT, use_reloader=False)

1211
web/templates/miniapp.html Normal file

File diff suppressed because it is too large Load diff