This commit is contained in:
dv 2026-03-15 21:36:03 +03:00
parent e94e1a3faa
commit 02143a9b48
15 changed files with 1045 additions and 427 deletions

5
.gitignore vendored
View file

@ -17,4 +17,7 @@
# Прочее частое
*.secret
secrets.json
config.local.*
config.local.*
#
intradbot-docker.zip

88
.woodpecker.yml Normal file
View file

@ -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

View file

@ -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()"]

195
bot/callbacks.py Normal file
View 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
View 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
View 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

146
bot/notifier.py Normal file
View file

@ -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",
))

134
bot/poster.py Normal file
View file

@ -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}")

View file

@ -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)

44
deploy/docker-compose.yml Normal file
View file

@ -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"

View file

@ -7,4 +7,10 @@ services:
- "8080:8080"
volumes:
- ./data:/app/data
- ./ssl:/app/ssl:ro # ← реальные файлы без симлинков
- ./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

71
gunicorn.conf.py Normal file
View file

@ -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.")

View file

@ -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

View file

@ -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")

59
scripts/rollback.sh Normal file
View file

@ -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"