diff --git a/.gitignore b/.gitignore index dbe39c6..b432071 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,7 @@ # Прочее частое *.secret secrets.json -config.local.* \ No newline at end of file +config.local.* + +# +intradbot-docker.zip \ No newline at end of file diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..89ba42f --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,88 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Woodpecker CI Pipeline +# +# Шаги: +# 1. docker-build — сборка и пуш образа (push в main) +# 2. docker-build-tag — сборка и пуш образа с semver-тегом (git tag v*) +# 3. deploy — деплой на сервер (выполняется на агенте role=production) +# +# Секреты (Settings → Secrets репозитория): +# forgejo_host — хост реестра, например git.example.com +# forgejo_image — полный путь образа, например git.example.com/user/intradbot +# forgejo_username — логин (обычно = gitea.actor) +# forgejo_token — токен с правом package:write +# ───────────────────────────────────────────────────────────────────────────── + +steps: + + # ── 1а. Сборка при push в main ───────────────────────────────────────────── + - name: docker-build + image: woodpeckerci/plugin-docker-buildx + settings: + registry: + from_secret: forgejo_host + repo: + from_secret: forgejo_image + username: + from_secret: forgejo_username + password: + from_secret: forgejo_token + tags: + - latest + - "sha-${CI_COMMIT_SHA:0:8}" + when: + - event: push + branch: main + + # ── 1б. Сборка при git tag v* (semver) ──────────────────────────────────── + - name: docker-build-tag + image: woodpeckerci/plugin-docker-buildx + settings: + registry: + from_secret: forgejo_host + repo: + from_secret: forgejo_image + username: + from_secret: forgejo_username + password: + from_secret: forgejo_token + tags: + - "${CI_COMMIT_TAG}" # v1.2.3 + - latest + - "sha-${CI_COMMIT_SHA:0:8}" # всегда — нужно для отката + when: + - event: tag + + # ── 2. Деплой на продакшн-сервер ────────────────────────────────────────── + # Выполняется на Woodpecker-агенте с меткой role=production. + # Требует: репозиторий помечен «trusted» в настройках Woodpecker-сервера + # (иначе монтирование volumes запрещено). + - name: deploy + image: docker:cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/intradbot:/deploy + commands: + - | + set -e + cd /deploy + + # Сохраняем ID текущего контейнерного образа (для отката) + CONTAINER=$(docker compose ps -q intradbot 2>/dev/null | head -1) + if [ -n "$CONTAINER" ]; then + OLD_IMAGE=$(docker inspect --format='{{.Image}}' "$CONTAINER") + printf '%s' "$OLD_IMAGE" > .previous_image + echo "Snapshot for rollback: $OLD_IMAGE" + fi + + # Обновляем образ и перезапускаем + docker compose pull + docker compose up -d --remove-orphans + + echo "Deployed: ${CI_COMMIT_SHA:0:8} (${CI_COMMIT_MESSAGE})" + labels: + role: production + when: + - event: push + branch: main + - event: tag diff --git a/Dockerfile b/Dockerfile index f4324ee..18ca803 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -# Gunicorn запускает Flask в продакшене. -# intradbot.py сам запускает бота и шедулер в отдельных потоках, -# а Gunicorn обслуживает HTTP — один worker достаточно для webhook-нагрузки. -CMD ["python", "intradbot.py"] +# Продакшен: Gunicorn (gthread, 1 worker) обслуживает Flask. +# Telegram-бот и шедулер стартуют внутри воркера через gunicorn.conf.py (post_fork). +# Локальная разработка: python intradbot.py +CMD ["gunicorn", "-c", "gunicorn.conf.py", "web.server:create_app()"] diff --git a/bot/callbacks.py b/bot/callbacks.py new file mode 100644 index 0000000..a8dd53a --- /dev/null +++ b/bot/callbacks.py @@ -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) diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..4094b1f --- /dev/null +++ b/bot/keyboards.py @@ -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 diff --git a/bot/messages.py b/bot/messages.py new file mode 100644 index 0000000..fa210af --- /dev/null +++ b/bot/messages.py @@ -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 diff --git a/bot/notifier.py b/bot/notifier.py new file mode 100644 index 0000000..77c46b7 --- /dev/null +++ b/bot/notifier.py @@ -0,0 +1,146 @@ +""" +Абстракция канала уведомлений. + +Паттерн «Адаптер»: весь код бота работает с интерфейсом Notifier, +не зная о конкретном мессенджере. + +Чтобы добавить новый канал (Slack, Email, Teams и т.д.) — создай класс, +реализующий Notifier, и зарегистрируй его через poster.set_notifier(). + +Структура клавиатуры (Keyboard) тоже агностична — адаптеры конвертируют +её в нативный формат своего мессенджера. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +# ── Агностичное представление клавиатуры ────────────────────────────────────── + +@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: + msg = self._bot.send_message( + self._channel_id, text, + parse_mode="Markdown", + disable_web_page_preview=True, + reply_markup=self._to_markup(keyboard), + ) + return msg.message_id + + def edit_text(self, message_id: int, text: str, keyboard: Keyboard | None = None): + 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): + self._bot.edit_message_reply_markup( + self._channel_id, message_id, + reply_markup=self._to_markup(keyboard), + ) + + def delete(self, message_id: int): + try: + self._bot.delete_message(self._channel_id, message_id) + except Exception: + pass + + def send_animation(self, url: str, disable_notification: bool = True) -> int: + msg = self._bot.send_animation( + self._channel_id, url, + disable_notification=disable_notification, + ) + 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", + )) diff --git a/bot/poster.py b/bot/poster.py new file mode 100644 index 0000000..169f3f3 --- /dev/null +++ b/bot/poster.py @@ -0,0 +1,134 @@ +""" +Управление постами в канале. + +Использует интерфейс Notifier — не привязан к Telegram или любому другому +мессенджеру. Инициализируется один раз при старте бота через set_notifier(). + +Чтобы сменить канал: вызови set_notifier() с другим адаптером. +Чтобы отправлять в несколько каналов: используй MultiNotifier (создай адаптер). +""" +from __future__ import annotations + +import logging + +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, +): + """ + Удаляет старый пост задачи и создаёт новый, накапливая события. + + Изменения (• ...) дедуплицируются по тексту. + Комментарии (💬 ...) и описания (📝 ...) добавляются всегда. + """ + 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) + if old: + notifier.delete(old["message_id"]) + + 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}") + + +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}") diff --git a/bot/telegram.py b/bot/telegram.py index 3faa94b..aac308a 100644 --- a/bot/telegram.py +++ b/bot/telegram.py @@ -1,420 +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(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, - ) - # save_message_id сначала создаёт/обновляет строку, потом сохраняем события - database.save_message_id(task_id, msg.message_id, task_number, group_id, base_text) - if added: - database.save_stored_events(task_id, stored) - except ApiTelegramException as e: - log.error(f"recreate_post failed for task {task_id}: {e}") - - -# ── Callbacks: смена статуса ────────────────────────────────────────────────── - -@bot.callback_query_handler(func=lambda c: c.data.startswith("st:")) -def cb_show_statuses(call): - task_number, 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", - "bad request", - )) - - -def _restore_keyboard(call, task_number: str, group_id: int): - """Восстанавливает основные кнопки на актуальном посте задачи.""" - chat_id, msg_id = _current_msg(task_number, call) - try: - bot.edit_message_reply_markup( - chat_id, msg_id, - reply_markup=make_task_buttons(task_number, group_id), - ) - except Exception as e: - if not _is_stale_error(e): - log.warning(f"restore_keyboard failed: {e}") - - -def _refresh_responsible(call, task_id: int, task_number: str): - """Редактирует текст актуального поста из БД — обновляет список ответственных.""" - data = database.get_message_data(task_id) - if not data or not data.get("base_text"): - return - - responsible = database.get_responsible(task_id) - events_lines = database.get_stored_events(task_id) - full_text = _build_full_text(data["base_text"], responsible, events_lines, new_count=0) - markup = make_task_buttons(task_number, data.get("group_id") or 0) - - try: - bot.edit_message_text( - full_text, - config.CHANNEL_ID, - data["message_id"], - parse_mode="Markdown", - disable_web_page_preview=True, - reply_markup=markup, - ) - except ApiTelegramException as e: - if not _is_stale_error(e): - log.warning(f"_refresh_responsible failed: {e}") - - -def _delete_message(chat_id, message_id): - 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) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..3dd693d --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,44 @@ +# ───────────────────────────────────────────────────────────────────────────── +# ПРОДАКШН docker-compose.yml — размещать на сервере в /opt/intradbot/ +# ───────────────────────────────────────────────────────────────────────────── +# +# Первоначальный запуск: +# mkdir -p /opt/intradbot/{data,ssl} +# cp deploy/docker-compose.yml /opt/intradbot/docker-compose.yml +# cp .env.example /opt/intradbot/.env && nano /opt/intradbot/.env +# cp /path/to/cert.pem /opt/intradbot/ssl/ +# cp /path/to/key.pem /opt/intradbot/ssl/ +# docker login git.example.com +# cd /opt/intradbot && docker compose up -d +# +# Обновление (вручную, если нет CI): +# cd /opt/intradbot && docker compose pull && docker compose up -d +# +# Полезные команды: +# docker compose logs -f — логи в реальном времени +# docker compose ps — статус +# docker compose exec intradbot bash +# ───────────────────────────────────────────────────────────────────────────── + +services: + intradbot: + image: git.example.com/YOUR_USER/intradbot:latest # ← замени на свой хост/юзер + restart: always + env_file: .env + ports: + - "8080:8080" + volumes: + - ./data:/app/data # SQLite БД + - ./ssl:/app/ssl:ro # cert.pem + key.pem + pull_policy: always + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" diff --git a/docker-compose.yml b/docker-compose.yml index 4985bd1..5f7e3ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,4 +7,10 @@ services: - "8080:8080" volumes: - ./data:/app/data - - ./ssl:/app/ssl:ro # ← реальные файлы без симлинков \ No newline at end of file + - ./ssl:/app/ssl:ro # ← реальные файлы без симлинков + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s \ No newline at end of file diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..86ad7a1 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,71 @@ +""" +Конфигурация Gunicorn для продакшена. + +Архитектура: + - 1 воркер (workers=1) — обязательно, т.к.: + * SQLite не поддерживает несколько независимых процессов без WAL + * SSE event bus (events.py) хранит состояние в памяти одного процесса + * Notifier (poster.py) инициализируется один раз в памяти воркера + - gthread: несколько потоков внутри воркера — позволяет обслуживать + одновременно SSE-поток дашборда + вебхуки + API-запросы + - post_fork: стартует Telegram-бот и шедулер внутри воркера (не в мастере) + +Локальная разработка: python intradbot.py (Flask dev server) +Продакшен (Docker): gunicorn -c gunicorn.conf.py "web.server:create_app()" +""" + +import os +import threading +import time + +# ── Сервер ──────────────────────────────────────────────────────────────────── + +bind = f"0.0.0.0:{os.environ.get('PORT', '8080')}" +workers = 1 # строго 1 — см. комментарий выше +worker_class = "gthread" # многопоточный воркер для SSE + вебхуков одновременно +threads = 4 # потоков: SSE-клиенты + API-запросы + запас +timeout = 120 # достаточно для долгих SSE-соединений +keepalive = 5 + +# ── Логирование ─────────────────────────────────────────────────────────────── + +accesslog = "-" # stdout +errorlog = "-" # stderr +loglevel = "info" +access_log_format = '%(h)s "%(r)s" %(s)s %(b)s %(D)sµs' + +# ── Хуки жизненного цикла ──────────────────────────────────────────────────── + +def on_starting(server): + """Инициализация до форка воркеров (выполняется в мастер-процессе).""" + import config + import database + from intradesk import reference + + os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True) + database.init_db() + reference.seed_static() + reference.load_all() + server.log.info("Database and reference data initialized.") + + +def post_fork(server, worker): + """ + Выполняется в каждом воркер-процессе после fork. + Запускает Telegram-бот и шедулер как daemon-потоки внутри воркера. + """ + import schedule + from intradesk import reference + from bot.telegram import start_bot + + threading.Thread(target=start_bot, daemon=True, name="telegram-bot").start() + + schedule.every().monday.at("10:00").do(reference.load_all) + + def _run_scheduler(): + while True: + schedule.run_pending() + time.sleep(1) + + threading.Thread(target=_run_scheduler, daemon=True, name="scheduler").start() + worker.log.info("Telegram bot and scheduler started in worker.") diff --git a/intradesk/formatter.py b/intradesk/formatter.py index f4d7581..2fefc69 100644 --- a/intradesk/formatter.py +++ b/intradesk/formatter.py @@ -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", @@ -134,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: @@ -142,22 +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 "Неизвестный" - # ── Комментарии ────────────────────────────────────────────────────────── - if etype in (50, 55): - text = event.get("stringvalue") or "" - if not text: - return None - text, had_quote = _strip_quote(text) - if not text: - return None - prefix = "Ответ на прошлое: " if had_quote else "" - lock = " 🔒" if etype == 55 else "" - return ParsedEvent("comment", f"*{short_user}*{lock}: {prefix}{text}") + # ── Специальные обработчики по типу события ─────────────────────────────── + if etype in _ETYPE_HANDLERS: + return _ETYPE_HANDLERS[etype](event, username) if blockname in IGNORED_FIELDS: return None diff --git a/intradesk/handler.py b/intradesk/handler.py index 8323491..4bd7392 100644 --- a/intradesk/handler.py +++ b/intradesk/handler.py @@ -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") diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100644 index 0000000..97554a1 --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────────────── +# Откат к предыдущему образу +# +# Использование (на сервере): +# bash /opt/intradbot/scripts/rollback.sh +# +# Как работает: +# Перед каждым деплоем Woodpecker сохраняет ID текущего образа +# в /opt/intradbot/.previous_image. +# Этот скрипт перетегирует сохранённый образ как :latest и перезапускает +# контейнер без обращения к registry. +# ───────────────────────────────────────────────────────────────────────────── +set -e + +DEPLOY_DIR="/opt/intradbot" +COMPOSE_FILE="$DEPLOY_DIR/docker-compose.yml" +PREV_FILE="$DEPLOY_DIR/.previous_image" + +cd "$DEPLOY_DIR" + +if [ ! -f "$PREV_FILE" ]; then + echo "ERROR: файл $PREV_FILE не найден — предыдущий деплой не был записан" + exit 1 +fi + +PREV_IMAGE=$(cat "$PREV_FILE" | tr -d '[:space:]') +if [ -z "$PREV_IMAGE" ]; then + echo "ERROR: $PREV_FILE пустой" + exit 1 +fi + +# Проверяем, что образ ещё есть локально (не был удалён docker prune) +if ! docker image inspect "$PREV_IMAGE" > /dev/null 2>&1; then + echo "ERROR: образ $PREV_IMAGE не найден локально — используй ручной откат:" + echo " docker compose pull # затем подставь нужный sha-тег в docker-compose.yml" + exit 1 +fi + +# Определяем полное имя образа из docker-compose.yml +TARGET_IMAGE=$(grep 'image:' "$COMPOSE_FILE" | awk '{print $2}' | head -1) +if [ -z "$TARGET_IMAGE" ]; then + echo "ERROR: не удалось извлечь image из $COMPOSE_FILE" + exit 1 +fi + +echo "Откат:" +echo " образ сейчас : $(docker compose ps -q intradbot 2>/dev/null | xargs -r docker inspect --format='{{.Image}}' | head -1 || echo 'нет контейнера')" +echo " откатываемся : $PREV_IMAGE" + +# Перетегируем старый образ как :latest локально +docker tag "$PREV_IMAGE" "$TARGET_IMAGE" + +# Перезапускаем без pull (используем локальный образ) +docker compose up -d --no-build + +echo "" +echo "Откат выполнен. Текущий образ: $TARGET_IMAGE" +echo "Для проверки: docker compose ps"