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
|
||||
|
||||
# Обновляем образ и перезапускаем
|
||||
docker compose -p intrabot pull
|
||||
docker compose -p intrabot up -d --remove-orphans
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
echo "Deployed: ${CI_COMMIT_SHA:0:8} (${CI_COMMIT_MESSAGE})"
|
||||
labels:
|
||||
role: aeza_woodpecker
|
||||
role: work_woodpecker
|
||||
when:
|
||||
- event: push
|
||||
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 time
|
||||
|
||||
import telebot
|
||||
from telebot import types
|
||||
from telebot.apihelper import ApiTelegramException
|
||||
|
||||
import config
|
||||
import database
|
||||
from intradesk import reference
|
||||
from intradesk import api as intradesk_api
|
||||
from intradesk import formatter
|
||||
from bot import callbacks
|
||||
from bot.notifier import TelegramChannelNotifier
|
||||
from bot import poster
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
bot = telebot.TeleBot(config.TOKEN, parse_mode="Markdown")
|
||||
|
||||
# Сотрудники, доступные для назначения исполнителем (ID из Intradesk)
|
||||
EXECUTOR_USER_IDS = config.EXECUTOR_USER_IDS
|
||||
|
||||
# Разделитель перед секцией "В работе" в тексте поста
|
||||
_RESP_SEP = "\n\n─────────────────────"
|
||||
@bot.callback_query_handler(func=lambda c: True)
|
||||
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():
|
||||
log.info("Telegram bot started (polling)...")
|
||||
notifier = TelegramChannelNotifier(bot, config.CHANNEL_ID)
|
||||
poster.set_notifier(notifier)
|
||||
bot.infinity_polling(timeout=60)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Точка входа IntraBot.
|
||||
Точка входа IntradBot.
|
||||
|
||||
Запускает три компонента:
|
||||
1. Telegram бот (polling) — в отдельном потоке
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
"""
|
||||
Парсинг и форматирование данных из вебхуков и 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 Literal
|
||||
from typing import Callable, Literal
|
||||
|
||||
import database
|
||||
from intradesk import reference
|
||||
|
|
@ -20,6 +28,16 @@ _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",
|
||||
|
|
@ -87,15 +105,6 @@ 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)
|
||||
|
|
@ -143,6 +152,44 @@ 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:
|
||||
|
|
@ -151,24 +198,16 @@ 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 (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 etype in _ETYPE_HANDLERS:
|
||||
return _ETYPE_HANDLERS[etype](event, username)
|
||||
|
||||
if blockname in IGNORED_FIELDS:
|
||||
return None
|
||||
|
|
@ -198,8 +237,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 = escape_markdown(_extract_name(val_new, blockname))
|
||||
old_str = escape_markdown(_extract_name(val_old, blockname)) if val_old else None
|
||||
new_str = _extract_name(val_new, blockname)
|
||||
old_str = _extract_name(val_old, blockname) if val_old else None
|
||||
if not new_str and not old_str:
|
||||
return None
|
||||
if new_str and old_str:
|
||||
|
|
@ -216,14 +255,11 @@ 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}*")
|
||||
|
||||
# ── Событие без значения ──────────────────────────────────────────────────
|
||||
|
|
@ -244,7 +280,6 @@ 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}"
|
||||
|
||||
|
||||
|
|
@ -389,12 +424,12 @@ def format_notification(payload: dict) -> str:
|
|||
op_type = payload.get("Event", {}).get("OperationType", "")
|
||||
fields = payload.get("Fields", {})
|
||||
number = fields.get("Number", "?")
|
||||
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))
|
||||
name = _scalar(fields.get("name")) or "—"
|
||||
applicant = get_initiator(fields)
|
||||
executors = get_all_executors(fields)
|
||||
obj_name = get_object_name(fields)
|
||||
to_flag = is_to(fields)
|
||||
status = escape_markdown(_resolve_field(fields, "status", "statuses"))
|
||||
status = _resolve_field(fields, "status", "statuses")
|
||||
|
||||
header = f"🆕 *Новая #{number}*" if op_type == "Created" \
|
||||
else f"🔄 *#{number} обновлена*"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from datetime import datetime
|
|||
|
||||
import database
|
||||
import events
|
||||
from bot import telegram as tg
|
||||
from bot import poster
|
||||
from intradesk import formatter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -54,7 +54,7 @@ def handle_webhook(payload: dict):
|
|||
"formatted": base_text,
|
||||
})
|
||||
|
||||
tg.recreate_post(
|
||||
poster.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:
|
||||
tg.send_gif_for_to(task_id)
|
||||
poster.send_gif_for_to(task_id)
|
||||
|
||||
except Exception:
|
||||
log.exception("Error while handling webhook")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
|
||||
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__)
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -106,6 +125,14 @@ 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("/")
|
||||
|
|
@ -145,7 +172,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))
|
||||
|
|
@ -158,7 +185,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
|
||||
|
|
@ -168,7 +195,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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue