Compare commits

..

6 commits

Author SHA1 Message Date
dv
f5b1643f53 еще попытка фикса гцупа
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-08 11:00:31 +03:00
dv
585cc374ed фикс заявок с гцупом
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-06 15:36:26 +03:00
dv
43607bf9d0 123
All checks were successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-20 11:30:23 +03:00
dv
570e6a3fed fix deploy
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-18 17:44:16 +03:00
dv
858acfbf5c ???
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-18 15:38:05 +03:00
dv
5770829021 попытка отката 2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
2026-03-16 00:50:30 +03:00
11 changed files with 501 additions and 846 deletions

View file

@ -84,12 +84,12 @@ steps:
fi
# Обновляем образ и перезапускаем
docker compose pull
docker compose up -d --remove-orphans
docker compose -p intrabot pull
docker compose -p intrabot up -d --remove-orphans
echo "Deployed: ${CI_COMMIT_SHA:0:8} (${CI_COMMIT_MESSAGE})"
labels:
role: work_woodpecker
role: aeza_woodpecker
when:
- event: push
branch: main

View file

@ -1,195 +0,0 @@
"""
Реестр callback-обработчиков.
Добавить новый callback:
@callback("my_prefix")
def _cb_my_action(call, bot):
...
Диспетчер telegram.py вызывает dispatch(call, bot), который находит
нужный обработчик по префиксу callback_data.
"""
from __future__ import annotations
import logging
from typing import Callable
import database
from intradesk import api as intradesk_api
from intradesk import reference
from bot.keyboards import (
build_executor_keyboard,
build_status_keyboard,
build_task_keyboard,
)
from bot import poster
log = logging.getLogger(__name__)
_registry: dict[str, Callable] = {}
def callback(prefix: str):
"""Декоратор: регистрирует функцию как обработчик callback с данным префиксом."""
def decorator(fn: Callable) -> Callable:
_registry[prefix] = fn
return fn
return decorator
def dispatch(call, bot) -> None:
"""Роутер: разбирает префикс из callback_data и вызывает нужный обработчик."""
prefix = call.data.split(":")[0]
handler = _registry.get(prefix)
if handler:
handler(call, bot)
else:
log.debug(f"Unhandled callback prefix: {prefix!r}")
# ── Вспомогательные функции ───────────────────────────────────────────────────
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 _get_message_id(task_number: str, call) -> int:
"""Возвращает актуальный 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 data["message_id"]
return call.message.message_id
def _restore_keyboard(bot, call, task_number: str, group_id: int):
"""Восстанавливает главную клавиатуру задачи на актуальном посте."""
notifier = poster.get_notifier()
msg_id = _get_message_id(task_number, call)
try:
notifier.edit_keyboard(msg_id, build_task_keyboard({
"task_number": task_number,
"group_id": group_id,
}))
except Exception as e:
if not notifier.is_stale_error(e):
log.warning(f"restore_keyboard failed: {e}")
def _answer_stale_or_error(bot, call, exc: Exception):
notifier = poster.get_notifier()
if notifier.is_stale_error(exc):
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
else:
bot.answer_callback_query(call.id, str(exc), show_alert=True)
# ── Обработчики ───────────────────────────────────────────────────────────────
@callback("st")
def _cb_show_statuses(call, bot):
task_number, group_id = _parse2(call.data[3:])
notifier = poster.get_notifier()
msg_id = _get_message_id(task_number, call)
try:
notifier.edit_keyboard(msg_id, build_status_keyboard(task_number, group_id))
bot.answer_callback_query(call.id)
except Exception as e:
_answer_stale_or_error(bot, call, e)
@callback("ss")
def _cb_set_status(call, bot):
# 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}")
if intradesk_api.change_status(int(task_number), status_id):
bot.answer_callback_query(call.id, f"✅ Статус → {status_name}")
_restore_keyboard(bot, call, task_number, group_id)
else:
bot.answer_callback_query(call.id, "❌ Ошибка изменения статуса", show_alert=True)
@callback("ex")
def _cb_show_executors(call, bot):
task_number, group_id = _parse2(call.data[3:])
notifier = poster.get_notifier()
msg_id = _get_message_id(task_number, call)
try:
notifier.edit_keyboard(msg_id, build_executor_keyboard(task_number, group_id))
bot.answer_callback_query(call.id)
except Exception as e:
_answer_stale_or_error(bot, call, e)
@callback("se")
def _cb_set_executor(call, bot):
# 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}"
if intradesk_api.assign_executor(int(task_number), user_id=emp_id, group_id=group_id or None):
bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}")
_restore_keyboard(bot, call, task_number, group_id)
else:
bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True)
@callback("su")
def _cb_unset_executor(call, bot):
"""Убрать конкретного исполнителя, оставить только группу."""
task_number, group_id = _parse2(call.data[3:])
if not group_id:
bot.answer_callback_query(call.id, "Группа не определена", show_alert=True)
return
if intradesk_api.assign_executor(int(task_number), group_id=group_id):
bot.answer_callback_query(call.id, "✅ Исполнитель снят, группа сохранена")
_restore_keyboard(bot, call, task_number, group_id)
else:
bot.answer_callback_query(call.id, "❌ Ошибка", show_alert=True)
@callback("take")
def _cb_take(call, bot):
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} в работу")
poster.refresh_responsible(task_id, task_number)
@callback("drop")
def _cb_drop(call, bot):
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}")
poster.refresh_responsible(task_id, task_number)
@callback("back")
def _cb_back(call, bot):
task_number, group_id = _parse2(call.data[5:])
bot.answer_callback_query(call.id)
_restore_keyboard(bot, call, task_number, group_id)

View file

@ -1,163 +0,0 @@
"""
Описание кнопок как данных.
Чтобы добавить или убрать кнопку редактируй TASK_BUTTONS.
Чтобы добавить новый тип клавиатуры добавь список ButtonSpec + функцию build_*_keyboard().
Keyboard (агностичный тип) конвертируется в нативный формат
в адаптере (см. bot/notifier.py).
"""
from __future__ import annotations
import hashlib
import hmac
import time
from dataclasses import dataclass, field
from typing import Callable
import config
from bot.notifier import Keyboard, KeyboardButton
# ── Спецификация кнопки ────────────────────────────────────────────────────────
@dataclass
class ButtonSpec:
"""
Описание одной кнопки главной клавиатуры задачи.
url_factory функция(ctx) str, для URL-кнопок
callback_prefix строковый префикс callback_data, для inline-кнопок
condition функция(ctx) bool, показывать ли кнопку (по умолчанию: всегда)
"""
label: str
url_factory: Callable[[dict], str] | None = None
callback_prefix: str | None = None
condition: Callable[[dict], bool] = field(
default_factory=lambda: (lambda _ctx: True)
)
# ── Вспомогательная функция: подписанная ссылка на Mini App ───────────────────
def _sign_task_url(task_number: str) -> str:
"""Генерирует HMAC-подписанную ссылку на 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}"
# ── Главная клавиатура задачи ─────────────────────────────────────────────────
#
# Добавить кнопку → добавь ButtonSpec в TASK_BUTTONS
# Убрать кнопку → удали строку из TASK_BUTTONS
# Временно скрыть → раскомментируй condition или закомментируй весь ButtonSpec
#
TASK_BUTTONS: list[ButtonSpec] = [
ButtonSpec(
label="📄 Предпросмотр",
url_factory=lambda ctx: _sign_task_url(ctx["task_number"]),
condition=lambda ctx: bool(config.PUBLIC_URL),
),
ButtonSpec(
label="🔗 Открыть в IntraDesk",
url_factory=lambda ctx: config.BASE_TASK_URL + ctx["task_number"],
),
# ── Управляющие кнопки (раскомментируй для включения) ─────────────────────
# ButtonSpec(
# label="📋 Статус",
# callback_prefix="st",
# condition=lambda ctx: not ctx.get("closed", False),
# ),
# ButtonSpec(
# label="👤 Исполнитель",
# callback_prefix="ex",
# condition=lambda ctx: not ctx.get("closed", False),
# ),
# ButtonSpec(
# label="🙋 Взять",
# callback_prefix="take",
# condition=lambda ctx: not ctx.get("closed", False),
# ),
# ButtonSpec(
# label="❌ Снять",
# callback_prefix="drop",
# condition=lambda ctx: not ctx.get("closed", False),
# ),
]
def _make_callback_data(spec: ButtonSpec, ctx: dict) -> str:
task_number = ctx["task_number"]
group_id = ctx.get("group_id", 0)
# take/drop — без group_id
if spec.callback_prefix in ("take", "drop"):
return f"{spec.callback_prefix}:{task_number}"
return f"{spec.callback_prefix}:{task_number}:{group_id}"
def build_task_keyboard(ctx: dict) -> Keyboard:
"""
Строит главную клавиатуру задачи по TASK_BUTTONS.
ctx ожидает:
task_number : str
group_id : int (опционально)
closed : bool (опционально)
Раскладка: по 2 кнопки в ряд.
"""
buttons: list[KeyboardButton] = []
for spec in TASK_BUTTONS:
if spec.condition(ctx):
if spec.url_factory:
buttons.append(KeyboardButton(
label=spec.label,
url=spec.url_factory(ctx),
))
elif spec.callback_prefix:
buttons.append(KeyboardButton(
label=spec.label,
callback_data=_make_callback_data(spec, ctx),
))
return [buttons[i:i + 2] for i in range(0, len(buttons), 2)]
# ── Клавиатура выбора статуса ─────────────────────────────────────────────────
def build_status_keyboard(task_number: str, group_id: int) -> Keyboard:
from intradesk import reference
rows: Keyboard = []
for sid, name in reference.STATUSES.items():
rows.append([KeyboardButton(
label=name,
callback_data=f"ss:{task_number}:{group_id}:{sid}",
)])
rows.append([KeyboardButton(
label="↩ Назад",
callback_data=f"back:{task_number}:{group_id}",
)])
return rows
# ── Клавиатура выбора исполнителя ─────────────────────────────────────────────
def build_executor_keyboard(task_number: str, group_id: int) -> Keyboard:
import database
rows: Keyboard = []
for uid in config.EXECUTOR_USER_IDS:
name = database.resolve("employees", uid) or f"ID {uid}"
rows.append([KeyboardButton(
label=name,
callback_data=f"se:{task_number}:{group_id}:{uid}",
)])
rows.append([KeyboardButton(
label=" Только группа",
callback_data=f"su:{task_number}:{group_id}",
)])
rows.append([KeyboardButton(
label="↩ Назад",
callback_data=f"back:{task_number}:{group_id}",
)])
return rows

View file

@ -1,44 +0,0 @@
"""
Построение текста постов.
Только текстовая логика без отправки, без Telegram API, без зависимостей.
Легко тестируется изолированно.
"""
_RESP_SEP = "\n\n─────────────────────"
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 = []
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

View file

@ -1,173 +0,0 @@
"""
Абстракция канала уведомлений.
Паттерн «Адаптер»: весь код бота работает с интерфейсом Notifier,
не зная о конкретном мессенджере.
Чтобы добавить новый канал (Slack, Email, Teams и т.д.) создай класс,
реализующий Notifier, и зарегистрируй его через poster.set_notifier().
Структура клавиатуры (Keyboard) тоже агностична адаптеры конвертируют
её в нативный формат своего мессенджера.
"""
from __future__ import annotations
import logging
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
log = logging.getLogger(__name__)
def _timed(label: str):
"""Context manager: логирует старт и завершение операции с временем выполнения."""
import contextlib
@contextlib.contextmanager
def _cm():
log.info("%s ...", label)
t0 = time.monotonic()
try:
yield
finally:
log.info("%s%.2fs", label, time.monotonic() - t0)
return _cm()
# ── Агностичное представление клавиатуры ──────────────────────────────────────
@dataclass
class KeyboardButton:
"""Одна кнопка: либо URL-ссылка, либо callback."""
label: str
url: str | None = None
callback_data: str | None = None
# Клавиатура — список строк, каждая строка — список кнопок
Keyboard = list[list[KeyboardButton]]
# ── Интерфейс отправителя ──────────────────────────────────────────────────────
class Notifier(ABC):
"""
Абстрактный адаптер канала уведомлений.
Реализации:
- TelegramChannelNotifier (этот файл)
- SlackNotifier (создай bot/adapters/slack.py)
- EmailNotifier (создай bot/adapters/email.py)
- ...
"""
@abstractmethod
def send(self, text: str, keyboard: Keyboard | None = None) -> int:
"""Отправить сообщение. Возвращает идентификатор сообщения."""
@abstractmethod
def edit_text(self, message_id: int, text: str, keyboard: Keyboard | None = None):
"""Редактировать текст существующего сообщения."""
@abstractmethod
def edit_keyboard(self, message_id: int, keyboard: Keyboard | None):
"""Заменить только клавиатуру существующего сообщения."""
@abstractmethod
def delete(self, message_id: int):
"""Удалить сообщение (ошибки поглощаются — сообщение могло быть удалено)."""
@abstractmethod
def send_animation(self, url: str, disable_notification: bool = True) -> int:
"""Отправить анимацию/GIF. Возвращает идентификатор сообщения."""
def is_stale_error(self, exc: Exception) -> bool:
"""
True если ошибка означает устаревшее/уже удалённое сообщение.
Адаптеры переопределяют под свою платформу.
"""
return False
# ── Telegram-адаптер ──────────────────────────────────────────────────────────
class TelegramChannelNotifier(Notifier):
"""Адаптер для отправки уведомлений в Telegram-канал через pytelegrambotapi."""
def __init__(self, bot, channel_id: int):
self._bot = bot
self._channel_id = channel_id
# ── Конвертация агностичной Keyboard → telebot InlineKeyboardMarkup ────────
def _to_markup(self, keyboard: Keyboard | None):
from telebot import types
if not keyboard:
return None
markup = types.InlineKeyboardMarkup()
for row in keyboard:
tg_row = []
for btn in row:
if btn.url:
tg_row.append(types.InlineKeyboardButton(btn.label, url=btn.url))
elif btn.callback_data:
tg_row.append(types.InlineKeyboardButton(btn.label, callback_data=btn.callback_data))
if tg_row:
markup.row(*tg_row)
return markup
# ── Реализация интерфейса ──────────────────────────────────────────────────
def send(self, text: str, keyboard: Keyboard | None = None) -> int:
with _timed(f"send_message → channel {self._channel_id}"):
msg = self._bot.send_message(
self._channel_id, text,
parse_mode="Markdown",
disable_web_page_preview=True,
reply_markup=self._to_markup(keyboard),
)
log.info("send_message OK → message_id=%s", msg.message_id)
return msg.message_id
def edit_text(self, message_id: int, text: str, keyboard: Keyboard | None = None):
with _timed(f"edit_message_text → message_id={message_id}"):
self._bot.edit_message_text(
text, self._channel_id, message_id,
parse_mode="Markdown",
disable_web_page_preview=True,
reply_markup=self._to_markup(keyboard),
)
def edit_keyboard(self, message_id: int, keyboard: Keyboard | None):
with _timed(f"edit_message_reply_markup → message_id={message_id}"):
self._bot.edit_message_reply_markup(
self._channel_id, message_id,
reply_markup=self._to_markup(keyboard),
)
def delete(self, message_id: int):
with _timed(f"delete_message → message_id={message_id}"):
try:
self._bot.delete_message(self._channel_id, message_id)
except Exception as e:
log.warning("delete_message failed (message_id=%s): %s", message_id, e)
def send_animation(self, url: str, disable_notification: bool = True) -> int:
with _timed(f"send_animation → channel {self._channel_id}"):
msg = self._bot.send_animation(
self._channel_id, url,
disable_notification=disable_notification,
)
log.info("send_animation OK → message_id=%s", msg.message_id)
return msg.message_id
def is_stale_error(self, exc: Exception) -> bool:
s = str(exc).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",
))

View file

@ -1,143 +0,0 @@
"""
Управление постами в канале.
Использует интерфейс Notifier не привязан к Telegram или любому другому
мессенджеру. Инициализируется один раз при старте бота через set_notifier().
Чтобы сменить канал: вызови set_notifier() с другим адаптером.
Чтобы отправлять в несколько каналов: используй MultiNotifier (создай адаптер).
"""
from __future__ import annotations
import logging
import time
import config
import database
from bot.keyboards import build_task_keyboard
from bot.messages import build_full_text
from bot.notifier import Notifier
log = logging.getLogger(__name__)
_notifier: Notifier | None = None
def set_notifier(notifier: Notifier) -> None:
"""Регистрирует активный адаптер уведомлений. Вызывается при старте бота."""
global _notifier
_notifier = notifier
def get_notifier() -> Notifier:
if _notifier is None:
raise RuntimeError("Notifier not initialized. Call set_notifier() first.")
return _notifier
# ── Публичные операции с постами ──────────────────────────────────────────────
def recreate_post(
task_id: int,
task_number: str,
base_text: str,
group_id: int = 0,
closed: bool = False,
new_event_lines: list[str] | None = None,
):
"""
Удаляет старый пост задачи и создаёт новый, накапливая события.
Изменения ( ...) дедуплицируются по тексту.
Комментарии (💬 ...) и описания (📝 ...) добавляются всегда.
"""
t0 = time.monotonic()
log.info("recreate_post START task=%s", task_id)
notifier = get_notifier()
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)
stored = stored + added
else:
added = []
responsible = database.get_responsible(task_id)
full_text = build_full_text(base_text, responsible, stored, new_count=len(added))
keyboard = build_task_keyboard({
"task_number": task_number,
"group_id": group_id,
"closed": closed,
})
old = database.get_message_data(task_id)
log.info("recreate_post task=%s old_msg=%s", task_id, old["message_id"] if old else None)
try:
message_id = notifier.send(full_text, keyboard)
database.save_message_id(task_id, message_id, task_number, group_id, base_text)
if added:
database.save_stored_events(task_id, stored)
except Exception as e:
log.error(f"recreate_post failed for task {task_id}: {e}")
return
if old:
notifier.delete(old["message_id"])
log.info("recreate_post DONE task=%s%.2fs total", task_id, time.monotonic() - t0)
def refresh_responsible(task_id: int, task_number: str):
"""Редактирует текст поста — обновляет блок «В работе»."""
notifier = get_notifier()
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)
keyboard = build_task_keyboard({
"task_number": task_number,
"group_id": data.get("group_id") or 0,
})
try:
notifier.edit_text(data["message_id"], full_text, keyboard)
except Exception as e:
if not notifier.is_stale_error(e):
log.warning(f"refresh_responsible failed: {e}")
def send_gif_for_to(task_id: int):
"""
Отправляет GIF для заявок с ТО.
Предварительно удаляет предыдущую гифку если была.
Если GIF_URL пустой ничего не делает.
"""
if not config.GIF_URL:
return
notifier = get_notifier()
old_gif_id = database.get_gif_message_id(task_id)
if old_gif_id:
notifier.delete(old_gif_id)
database.delete_gif_message_id(task_id)
try:
message_id = notifier.send_animation(config.GIF_URL, disable_notification=True)
database.save_gif_message_id(task_id, message_id)
log.info(f"GIF sent for task {task_id}, message_id={message_id}")
except Exception as e:
log.error(f"Failed to send GIF for task {task_id}: {e}")

View file

@ -1,35 +1,470 @@
"""
Точка запуска Telegram-бота.
Создаёт экземпляр бота, инициализирует адаптер уведомлений (TelegramChannelNotifier),
регистрирует единый callback-диспетчер.
Вся логика кнопок bot/keyboards.py
Вся логика текста bot/messages.py
Отправка/удаление bot/poster.py (через Notifier-адаптер)
Callback-обработчики bot/callbacks.py
"""
import hashlib
import hmac
import logging
import time
import telebot
from telebot import types
from telebot.apihelper import ApiTelegramException
import config
from bot import callbacks
from bot.notifier import TelegramChannelNotifier
from bot import poster
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
@bot.callback_query_handler(func=lambda c: True)
def _handle_callback(call):
callbacks.dispatch(call, bot)
# Разделитель перед секцией "В работе" в тексте поста
_RESP_SEP = "\n\n─────────────────────"
# ── Текст с футером "В работе" ────────────────────────────────────────────────
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(
formatter.escape_markdown(r["user_name"]) or 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("📄 Предпросмотр", 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 _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,
base_text: str,
group_id: int = 0,
closed: bool = False,
new_event_lines: list[str] | None = None,
):
"""Удаляет старый пост и создаёт новый, накапливая события и сохраняя ответственных."""
# Накапливаем события:
# - изменения (• ...) дедуплицируем по тексту — одно и то же изменение не дублируем
# - комментарии (💬 ...) и описание (📝 ...) всегда добавляем, даже если текст совпадает
stored = database.get_stored_events(task_id)
stored_set = set(stored)
if new_event_lines:
added = []
for line in new_event_lines:
if not line:
continue
is_change = line.startswith("")
if is_change and line in stored_set:
continue # дедупликация только для изменений
added.append(line)
stored_set.add(line) # обновляем set чтобы не дублировать внутри одного вебхука
stored = stored + added
else:
added = []
responsible = database.get_responsible(task_id)
full_text = _build_full_text(base_text, responsible, stored, new_count=len(added))
markup = make_task_buttons(task_number, group_id)
# Удаляем старый пост
old = database.get_message_data(task_id)
if old:
_delete_message(config.CHANNEL_ID, old["message_id"])
try:
msg = bot.send_message(
config.CHANNEL_ID,
full_text,
parse_mode="Markdown",
disable_web_page_preview=True,
reply_markup=markup,
)
log.info(f"recreate_post sent for task {task_id}, message_id={msg.message_id}")
# 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:
err_text = str(e).lower()
is_markdown_error = any(x in err_text for x in (
"can't parse entities",
"can't find end of the entity",
"entity starting at byte offset",
))
if is_markdown_error:
log.error(f"recreate_post markdown failed for task {task_id}, fallback to plain text: {e}")
try:
msg = bot.send_message(
config.CHANNEL_ID,
full_text,
parse_mode=None,
disable_web_page_preview=True,
reply_markup=markup,
)
database.save_message_id(task_id, msg.message_id, task_number, group_id, base_text)
if added:
database.save_stored_events(task_id, stored)
log.warning(f"recreate_post sent without markdown for task {task_id}, message_id={msg.message_id}")
except ApiTelegramException as e2:
log.error(f"recreate_post fallback failed for task {task_id}: {e2}")
else:
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, group_id = _parse2(call.data[3:])
chat_id, msg_id = _current_msg(task_number, call)
try:
bot.edit_message_reply_markup(
chat_id, msg_id,
reply_markup=_make_status_keyboard(task_number, group_id),
)
bot.answer_callback_query(call.id)
except Exception as e:
if _is_stale_error(e):
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
else:
bot.answer_callback_query(call.id, str(e), show_alert=True)
@bot.callback_query_handler(func=lambda c: c.data.startswith("ss:"))
def cb_set_status(call):
# 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), status_id)
if success:
bot.answer_callback_query(call.id, f"✅ Статус → {status_name}")
_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("ex:"))
def cb_show_executors(call):
task_number, group_id = _parse2(call.data[3:])
chat_id, msg_id = _current_msg(task_number, call)
try:
bot.edit_message_reply_markup(
chat_id, msg_id,
reply_markup=_make_executor_keyboard(task_number, group_id),
)
bot.answer_callback_query(call.id)
except Exception as e:
if _is_stale_error(e):
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
else:
bot.answer_callback_query(call.id, str(e), show_alert=True)
@bot.callback_query_handler(func=lambda c: c.data.startswith("se:"))
def cb_set_executor(call):
# 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=emp_id, group_id=group_id or None
)
if success:
bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}")
_restore_keyboard(call, task_number, group_id)
else:
bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True)
@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
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)
_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",
"message to delete not found",
"message_id_invalid",
))
def _is_missing_message_error(e: Exception) -> bool:
"""True for Telegram errors when target message no longer exists."""
s = str(e).lower()
return any(x in s for x in (
"message to edit not found",
"message to delete not found",
"message_id_invalid",
))
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 _is_missing_message_error(e):
log.warning(
f"_refresh_responsible: message missing for task {task_id}, recreating post"
)
recreate_post(
task_id=task_id,
task_number=task_number,
base_text=data["base_text"],
group_id=data.get("group_id") or 0,
new_event_lines=None,
)
return
if not _is_stale_error(e):
log.warning(f"_refresh_responsible failed: {e}")
def _delete_message(chat_id, message_id):
try:
bot.delete_message(chat_id, message_id)
except Exception:
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)...")
notifier = TelegramChannelNotifier(bot, config.CHANNEL_ID)
poster.set_notifier(notifier)
bot.infinity_polling(timeout=60)

View file

@ -1,5 +1,5 @@
"""
Точка входа IntradBot.
Точка входа IntraBot.
Запускает три компонента:
1. Telegram бот (polling) в отдельном потоке

View file

@ -1,19 +1,11 @@
"""
Парсинг и форматирование данных из вебхуков и API Intradesk.
Добавить парсер нового типа события:
@event_type_handler(ваш_тип)
def _parse_my_event(event: dict, username: str) -> ParsedEvent | None:
...
Добавить новое boolean доп. поле (ТО, Созвон и т.д.):
register_bool_field("addfield_xxxxBoolean", "Название поля")
"""
import json
import logging
import re
from dataclasses import dataclass
from typing import Callable, Literal
from typing import Literal
import database
from intradesk import reference
@ -28,16 +20,6 @@ _ADDFIELD_BOOL_LABELS: dict[str, str] = {
"addfield_2684bdczamena_oborudovaniiaBoolean": "Замена оборудования",
}
def register_bool_field(blockname: str, label: str) -> None:
"""
Регистрирует новое boolean доп. поле Intradesk.
Пример:
register_bool_field("addfield_abcdef123Boolean", "Срочный выезд")
"""
_ADDFIELD_BOOL_LABELS[blockname] = label
BLOCK_TO_ENTITY: dict[str, str] = {
"status": "statuses",
"priority": "priorities",
@ -105,6 +87,15 @@ def _shorten_name(name: str | None) -> str | None:
return name
def escape_markdown(text: str | None) -> str | None:
"""Escape user-provided text for Telegram legacy Markdown parse mode."""
if text is None:
return None
escaped = str(text).replace("\\", "\\\\")
for ch in ("_", "*", "`", "["):
escaped = escaped.replace(ch, f"\\{ch}")
return escaped
def _strip_quote(text: str) -> tuple[str, bool]:
"""Убирает блоки цитирования. Возвращает (cleaned_text, had_quote)."""
cleaned, n = _QUOTE_RE.subn("", text)
@ -152,44 +143,6 @@ def _extract_name(v: dict | None, blockname: str = "") -> str | None:
return str(item_id) if item_id else None
# ── Реестр парсеров специальных типов событий ─────────────────────────────────
_ETYPE_HANDLERS: dict[int, Callable] = {}
def event_type_handler(*etypes: int):
"""
Декоратор: регистрирует функцию как парсер событий указанных типов.
Пример добавить обработчик типа 99:
@event_type_handler(99)
def _parse_my_event(event: dict, username: str) -> ParsedEvent | None:
return ParsedEvent("change", f"• Мой тип: {event.get('stringvalue')}")
"""
def decorator(fn: Callable) -> Callable:
for etype in etypes:
_ETYPE_HANDLERS[etype] = fn
return fn
return decorator
# ── Встроенные парсеры специальных типов ──────────────────────────────────────
@event_type_handler(50, 55)
def _parse_comment(event: dict, username: str) -> ParsedEvent | None:
"""Комментарий (50) и приватный комментарий (55)."""
text = event.get("stringvalue") or ""
if not text:
return None
text, had_quote = _strip_quote(text)
if not text:
return None
short_user = _shorten_name(username) or "Неизвестный"
prefix = "Ответ на прошлое: " if had_quote else ""
lock = " 🔒" if event.get("type") == 55 else ""
return ParsedEvent("comment", f"*{short_user}*{lock}: {prefix}{text}")
# ── Ядро: разбор одного события ───────────────────────────────────────────────
def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
@ -198,16 +151,24 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
Используется как в parse_lifetime() (вебхук), так и в format_lifetime_api_events()
(прямой API). Единственный источник правды для логики разбора событий.
Специальные типы событий (50, 55, ...) обрабатываются через реестр
_ETYPE_HANDLERS. Для добавления нового типа используй @event_type_handler.
"""
etype = event.get("type")
blockname = event.get("blockname", "")
short_user = _shorten_name(username) or "Неизвестный"
short_user = escape_markdown(short_user) or short_user
# ── Специальные обработчики по типу события ───────────────────────────────
if etype in _ETYPE_HANDLERS:
return _ETYPE_HANDLERS[etype](event, username)
# ── Комментарии ──────────────────────────────────────────────────────────
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
text = escape_markdown(text) or ""
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
@ -237,8 +198,8 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
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
new_str = escape_markdown(_extract_name(val_new, blockname))
old_str = escape_markdown(_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:
@ -255,11 +216,14 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
if isinstance(attach, dict):
fname = attach.get("Name", "")
uploader = _shorten_name(attach.get("UploadedBy", "").strip() or None) or "?"
fname = escape_markdown(fname) or ""
uploader = escape_markdown(uploader) 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 "")
truncated = escape_markdown(truncated) or ""
return ParsedEvent("change", f"{label}: *{truncated}*")
# ── Событие без значения ──────────────────────────────────────────────────
@ -280,6 +244,7 @@ def get_description_event(fields: dict) -> str | None:
if not description:
return None
truncated = description[:100] + ('' if len(description) > 100 else '')
truncated = escape_markdown(truncated) or ""
return f"📝 {truncated}"
@ -424,12 +389,12 @@ 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 ""
applicant = get_initiator(fields)
executors = get_all_executors(fields)
obj_name = get_object_name(fields)
name = escape_markdown(_scalar(fields.get("name")) or "") or "-"
applicant = escape_markdown(get_initiator(fields))
executors = [escape_markdown(x) or "" for x in get_all_executors(fields)]
obj_name = escape_markdown(get_object_name(fields))
to_flag = is_to(fields)
status = _resolve_field(fields, "status", "statuses")
status = escape_markdown(_resolve_field(fields, "status", "statuses"))
header = f"🆕 *Новая #{number}*" if op_type == "Created" \
else f"🔄 *#{number} обновлена*"

View file

@ -3,7 +3,7 @@ from datetime import datetime
import database
import events
from bot import poster
from bot import telegram as tg
from intradesk import formatter
log = logging.getLogger(__name__)
@ -54,7 +54,7 @@ def handle_webhook(payload: dict):
"formatted": base_text,
})
poster.recreate_post(
tg.recreate_post(
task_id, str(number), base_text, group_id,
closed=closed, new_event_lines=new_event_lines,
)
@ -62,7 +62,7 @@ def handle_webhook(payload: dict):
# ── Гифка для ТО ──────────────────────────────────────────────────────
# Отправляем (и удаляем предыдущую) при любом вебхуке если ТО = да
if is_to_now:
poster.send_gif_for_to(task_id)
tg.send_gif_for_to(task_id)
except Exception:
log.exception("Error while handling webhook")

View file

@ -1,7 +1,6 @@
import json
import logging
import os
import queue
import threading
from flask import Flask, Response, jsonify, render_template, request, stream_with_context, abort
@ -20,24 +19,6 @@ from intradesk.handler import handle_webhook
log = logging.getLogger(__name__)
app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"))
# ── Очередь вебхуков (последовательная обработка) ─────────────────────────────
_webhook_queue: queue.Queue = queue.Queue()
def _webhook_worker():
while True:
data = _webhook_queue.get()
try:
handle_webhook(data)
except Exception:
log.exception("Unhandled error in webhook worker")
finally:
_webhook_queue.task_done()
threading.Thread(target=_webhook_worker, daemon=True, name="webhook-worker").start()
# ── Авторизация ────────────────────────────────────────────────────────────────
@ -116,7 +97,7 @@ def webhook():
if not data:
return jsonify({"error": "Invalid JSON"}), 400
_webhook_queue.put(data)
threading.Thread(target=handle_webhook, args=(data,), daemon=True).start()
return jsonify({"ok": True}), 200
@ -125,14 +106,6 @@ def health():
return jsonify({"status": "ok"}), 200
@app.route("/version")
def version():
return jsonify({
"commit": os.environ.get("GIT_COMMIT", "dev"),
"built_at": os.environ.get("BUILD_TIME", "unknown"),
})
# ── Дашборд ────────────────────────────────────────────────────────────────────
@app.route("/")
@ -172,7 +145,7 @@ def stream():
# ── API заявок ─────────────────────────────────────────────────────────────────
@app.route("/api/tasks/search")
#@require_task_token
@require_task_token
def api_tasks_search():
try:
number = int(request.args.get("number", 0))
@ -185,7 +158,7 @@ def api_tasks_search():
@app.route("/api/tasks/active")
#@require_task_token
@require_task_token
def api_tasks_active():
filter_expr = request.args.get(
"filter", config.ACTIVE_TASKS_FILTER
@ -195,7 +168,7 @@ def api_tasks_active():
@app.route("/api/task/lifetime")
#@require_task_token
@require_task_token
def api_task_lifetime():
try:
task_id = int(request.args.get("task_id", 0))