Compare commits
13 commits
main
...
master-bac
| Author | SHA1 | Date | |
|---|---|---|---|
| c48998e085 | |||
| 2338e5666a | |||
| 282b066fcf | |||
| a8723bb3ee | |||
| 88c4c84dec | |||
| c3332572c0 | |||
| 3f80530786 | |||
| 260ce5dfd3 | |||
| 453c657681 | |||
| eabb394368 | |||
| a441253b89 | |||
| 718438a934 | |||
| 02143a9b48 |
11 changed files with 846 additions and 501 deletions
|
|
@ -84,12 +84,12 @@ steps:
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Обновляем образ и перезапускаем
|
# Обновляем образ и перезапускаем
|
||||||
docker compose -p intrabot pull
|
docker compose pull
|
||||||
docker compose -p intrabot up -d --remove-orphans
|
docker compose up -d --remove-orphans
|
||||||
|
|
||||||
echo "Deployed: ${CI_COMMIT_SHA:0:8} (${CI_COMMIT_MESSAGE})"
|
echo "Deployed: ${CI_COMMIT_SHA:0:8} (${CI_COMMIT_MESSAGE})"
|
||||||
labels:
|
labels:
|
||||||
role: aeza_woodpecker
|
role: work_woodpecker
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|
|
||||||
195
bot/callbacks.py
Normal file
195
bot/callbacks.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""
|
||||||
|
Реестр 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)
|
||||||
163
bot/keyboards.py
Normal file
163
bot/keyboards.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""
|
||||||
|
Описание кнопок как данных.
|
||||||
|
|
||||||
|
Чтобы добавить или убрать кнопку — редактируй 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
|
||||||
44
bot/messages.py
Normal file
44
bot/messages.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""
|
||||||
|
Построение текста постов.
|
||||||
|
|
||||||
|
Только текстовая логика — без отправки, без 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
|
||||||
173
bot/notifier.py
Normal file
173
bot/notifier.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""
|
||||||
|
Абстракция канала уведомлений.
|
||||||
|
|
||||||
|
Паттерн «Адаптер»: весь код бота работает с интерфейсом 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",
|
||||||
|
))
|
||||||
143
bot/poster.py
Normal file
143
bot/poster.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""
|
||||||
|
Управление постами в канале.
|
||||||
|
|
||||||
|
Использует интерфейс 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}")
|
||||||
475
bot/telegram.py
475
bot/telegram.py
|
|
@ -1,470 +1,35 @@
|
||||||
import hashlib
|
"""
|
||||||
import hmac
|
Точка запуска Telegram-бота.
|
||||||
|
|
||||||
|
Создаёт экземпляр бота, инициализирует адаптер уведомлений (TelegramChannelNotifier),
|
||||||
|
регистрирует единый callback-диспетчер.
|
||||||
|
|
||||||
|
Вся логика кнопок → bot/keyboards.py
|
||||||
|
Вся логика текста → bot/messages.py
|
||||||
|
Отправка/удаление → bot/poster.py (через Notifier-адаптер)
|
||||||
|
Callback-обработчики → bot/callbacks.py
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
|
|
||||||
import telebot
|
import telebot
|
||||||
from telebot import types
|
|
||||||
from telebot.apihelper import ApiTelegramException
|
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import database
|
from bot import callbacks
|
||||||
from intradesk import reference
|
from bot.notifier import TelegramChannelNotifier
|
||||||
from intradesk import api as intradesk_api
|
from bot import poster
|
||||||
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
|
|
||||||
|
|
||||||
# Разделитель перед секцией "В работе" в тексте поста
|
@bot.callback_query_handler(func=lambda c: True)
|
||||||
_RESP_SEP = "\n\n─────────────────────"
|
def _handle_callback(call):
|
||||||
|
callbacks.dispatch(call, bot)
|
||||||
|
|
||||||
|
|
||||||
# ── Текст с футером "В работе" ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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():
|
def start_bot():
|
||||||
log.info("Telegram bot started (polling)...")
|
log.info("Telegram bot started (polling)...")
|
||||||
|
notifier = TelegramChannelNotifier(bot, config.CHANNEL_ID)
|
||||||
|
poster.set_notifier(notifier)
|
||||||
bot.infinity_polling(timeout=60)
|
bot.infinity_polling(timeout=60)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
Точка входа IntraBot.
|
Точка входа IntradBot.
|
||||||
|
|
||||||
Запускает три компонента:
|
Запускает три компонента:
|
||||||
1. Telegram бот (polling) — в отдельном потоке
|
1. Telegram бот (polling) — в отдельном потоке
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
"""
|
"""
|
||||||
Парсинг и форматирование данных из вебхуков и API Intradesk.
|
Парсинг и форматирование данных из вебхуков и API Intradesk.
|
||||||
|
|
||||||
|
Добавить парсер нового типа события:
|
||||||
|
@event_type_handler(ваш_тип)
|
||||||
|
def _parse_my_event(event: dict, username: str) -> ParsedEvent | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
Добавить новое boolean доп. поле (ТО, Созвон и т.д.):
|
||||||
|
register_bool_field("addfield_xxxxBoolean", "Название поля")
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Callable, Literal
|
||||||
|
|
||||||
import database
|
import database
|
||||||
from intradesk import reference
|
from intradesk import reference
|
||||||
|
|
@ -20,6 +28,16 @@ _ADDFIELD_BOOL_LABELS: dict[str, str] = {
|
||||||
"addfield_2684bdczamena_oborudovaniiaBoolean": "Замена оборудования",
|
"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] = {
|
BLOCK_TO_ENTITY: dict[str, str] = {
|
||||||
"status": "statuses",
|
"status": "statuses",
|
||||||
"priority": "priorities",
|
"priority": "priorities",
|
||||||
|
|
@ -87,15 +105,6 @@ def _shorten_name(name: str | None) -> str | None:
|
||||||
return name
|
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]:
|
def _strip_quote(text: str) -> tuple[str, bool]:
|
||||||
"""Убирает блоки цитирования. Возвращает (cleaned_text, had_quote)."""
|
"""Убирает блоки цитирования. Возвращает (cleaned_text, had_quote)."""
|
||||||
cleaned, n = _QUOTE_RE.subn("", text)
|
cleaned, n = _QUOTE_RE.subn("", text)
|
||||||
|
|
@ -143,6 +152,44 @@ def _extract_name(v: dict | None, blockname: str = "") -> str | None:
|
||||||
return str(item_id) if item_id else 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:
|
def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
|
|
@ -151,24 +198,16 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
|
|
||||||
Используется как в parse_lifetime() (вебхук), так и в format_lifetime_api_events()
|
Используется как в parse_lifetime() (вебхук), так и в format_lifetime_api_events()
|
||||||
(прямой API). Единственный источник правды для логики разбора событий.
|
(прямой API). Единственный источник правды для логики разбора событий.
|
||||||
|
|
||||||
|
Специальные типы событий (50, 55, ...) обрабатываются через реестр
|
||||||
|
_ETYPE_HANDLERS. Для добавления нового типа используй @event_type_handler.
|
||||||
"""
|
"""
|
||||||
etype = event.get("type")
|
etype = event.get("type")
|
||||||
blockname = event.get("blockname", "")
|
blockname = event.get("blockname", "")
|
||||||
short_user = _shorten_name(username) or "Неизвестный"
|
|
||||||
short_user = escape_markdown(short_user) or short_user
|
|
||||||
|
|
||||||
# ── Комментарии ──────────────────────────────────────────────────────────
|
# ── Специальные обработчики по типу события ───────────────────────────────
|
||||||
if etype in (50, 55):
|
if etype in _ETYPE_HANDLERS:
|
||||||
text = event.get("stringvalue") or ""
|
return _ETYPE_HANDLERS[etype](event, username)
|
||||||
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:
|
if blockname in IGNORED_FIELDS:
|
||||||
return None
|
return None
|
||||||
|
|
@ -198,8 +237,8 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
if entity_data:
|
if entity_data:
|
||||||
val_new = entity_data.get("valuenew") or {}
|
val_new = entity_data.get("valuenew") or {}
|
||||||
val_old = entity_data.get("valueold")
|
val_old = entity_data.get("valueold")
|
||||||
new_str = escape_markdown(_extract_name(val_new, blockname))
|
new_str = _extract_name(val_new, blockname)
|
||||||
old_str = escape_markdown(_extract_name(val_old, blockname)) if val_old else None
|
old_str = _extract_name(val_old, blockname) if val_old else None
|
||||||
if not new_str and not old_str:
|
if not new_str and not old_str:
|
||||||
return None
|
return None
|
||||||
if new_str and old_str:
|
if new_str and old_str:
|
||||||
|
|
@ -216,14 +255,11 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
if isinstance(attach, dict):
|
if isinstance(attach, dict):
|
||||||
fname = attach.get("Name", "")
|
fname = attach.get("Name", "")
|
||||||
uploader = _shorten_name(attach.get("UploadedBy", "").strip() or None) or "?"
|
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}")
|
return ParsedEvent("change", f"• 📎 {uploader} прикрепил: {fname}")
|
||||||
m = _FILE_EXT_RE.search(str_val.strip())
|
m = _FILE_EXT_RE.search(str_val.strip())
|
||||||
ext = m.group(1).upper() if m else "файл"
|
ext = m.group(1).upper() if m else "файл"
|
||||||
return ParsedEvent("change", f"• 📎 Добавлен файл .{ext}")
|
return ParsedEvent("change", f"• 📎 Добавлен файл .{ext}")
|
||||||
truncated = str_val[:80] + ("…" if len(str_val) > 80 else "")
|
truncated = str_val[:80] + ("…" if len(str_val) > 80 else "")
|
||||||
truncated = escape_markdown(truncated) or ""
|
|
||||||
return ParsedEvent("change", f"• {label}: *{truncated}*")
|
return ParsedEvent("change", f"• {label}: *{truncated}*")
|
||||||
|
|
||||||
# ── Событие без значения ──────────────────────────────────────────────────
|
# ── Событие без значения ──────────────────────────────────────────────────
|
||||||
|
|
@ -244,7 +280,6 @@ def get_description_event(fields: dict) -> str | None:
|
||||||
if not description:
|
if not description:
|
||||||
return None
|
return None
|
||||||
truncated = description[:100] + ('…' if len(description) > 100 else '')
|
truncated = description[:100] + ('…' if len(description) > 100 else '')
|
||||||
truncated = escape_markdown(truncated) or ""
|
|
||||||
return f"📝 {truncated}"
|
return f"📝 {truncated}"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -389,12 +424,12 @@ 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 = escape_markdown(_scalar(fields.get("name")) or "—") or "-"
|
name = _scalar(fields.get("name")) or "—"
|
||||||
applicant = escape_markdown(get_initiator(fields))
|
applicant = get_initiator(fields)
|
||||||
executors = [escape_markdown(x) or "" for x in get_all_executors(fields)]
|
executors = get_all_executors(fields)
|
||||||
obj_name = escape_markdown(get_object_name(fields))
|
obj_name = get_object_name(fields)
|
||||||
to_flag = is_to(fields)
|
to_flag = is_to(fields)
|
||||||
status = escape_markdown(_resolve_field(fields, "status", "statuses"))
|
status = _resolve_field(fields, "status", "statuses")
|
||||||
|
|
||||||
header = f"🆕 *Новая #{number}*" if op_type == "Created" \
|
header = f"🆕 *Новая #{number}*" if op_type == "Created" \
|
||||||
else f"🔄 *#{number} обновлена*"
|
else f"🔄 *#{number} обновлена*"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from datetime import datetime
|
||||||
|
|
||||||
import database
|
import database
|
||||||
import events
|
import events
|
||||||
from bot import telegram as tg
|
from bot import poster
|
||||||
from intradesk import formatter
|
from intradesk import formatter
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -54,7 +54,7 @@ def handle_webhook(payload: dict):
|
||||||
"formatted": base_text,
|
"formatted": base_text,
|
||||||
})
|
})
|
||||||
|
|
||||||
tg.recreate_post(
|
poster.recreate_post(
|
||||||
task_id, str(number), base_text, group_id,
|
task_id, str(number), base_text, group_id,
|
||||||
closed=closed, new_event_lines=new_event_lines,
|
closed=closed, new_event_lines=new_event_lines,
|
||||||
)
|
)
|
||||||
|
|
@ -62,7 +62,7 @@ def handle_webhook(payload: dict):
|
||||||
# ── Гифка для ТО ──────────────────────────────────────────────────────
|
# ── Гифка для ТО ──────────────────────────────────────────────────────
|
||||||
# Отправляем (и удаляем предыдущую) при любом вебхуке если ТО = да
|
# Отправляем (и удаляем предыдущую) при любом вебхуке если ТО = да
|
||||||
if is_to_now:
|
if is_to_now:
|
||||||
tg.send_gif_for_to(task_id)
|
poster.send_gif_for_to(task_id)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Error while handling webhook")
|
log.exception("Error while handling webhook")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context, abort
|
from flask import Flask, Response, jsonify, render_template, request, stream_with_context, abort
|
||||||
|
|
@ -19,6 +20,24 @@ from intradesk.handler import handle_webhook
|
||||||
log = logging.getLogger(__name__)
|
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"))
|
||||||
|
|
||||||
|
# ── Очередь вебхуков (последовательная обработка) ─────────────────────────────
|
||||||
|
|
||||||
|
_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()
|
||||||
|
|
||||||
|
|
||||||
# ── Авторизация ────────────────────────────────────────────────────────────────
|
# ── Авторизация ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -97,7 +116,7 @@ def webhook():
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"error": "Invalid JSON"}), 400
|
return jsonify({"error": "Invalid JSON"}), 400
|
||||||
|
|
||||||
threading.Thread(target=handle_webhook, args=(data,), daemon=True).start()
|
_webhook_queue.put(data)
|
||||||
return jsonify({"ok": True}), 200
|
return jsonify({"ok": True}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -106,6 +125,14 @@ def health():
|
||||||
return jsonify({"status": "ok"}), 200
|
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("/")
|
@app.route("/")
|
||||||
|
|
@ -145,7 +172,7 @@ def stream():
|
||||||
# ── API заявок ─────────────────────────────────────────────────────────────────
|
# ── API заявок ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/api/tasks/search")
|
@app.route("/api/tasks/search")
|
||||||
@require_task_token
|
#@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))
|
||||||
|
|
@ -158,7 +185,7 @@ def api_tasks_search():
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/tasks/active")
|
@app.route("/api/tasks/active")
|
||||||
@require_task_token
|
#@require_task_token
|
||||||
def api_tasks_active():
|
def api_tasks_active():
|
||||||
filter_expr = request.args.get(
|
filter_expr = request.args.get(
|
||||||
"filter", config.ACTIVE_TASKS_FILTER
|
"filter", config.ACTIVE_TASKS_FILTER
|
||||||
|
|
@ -168,7 +195,7 @@ def api_tasks_active():
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/task/lifetime")
|
@app.route("/api/task/lifetime")
|
||||||
@require_task_token
|
#@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))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue